Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
5.0 MiB
Plaintext
1 line
5.0 MiB
Plaintext
{"repo": "298118/qianqiu", "files": {"src/game/aiTraceDebug.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595218.7029746, "n": 13891, "h": "659bf6d5fce1cfd5", "c": "// @ts-check\n\nconst { AI_TASK_LABELS } = require(\"./aiSettingsConfig\");\nconst {\n ensureAiSettingsState,\n buildAiInvocationSummaryView,\n resolveAiSettingsForSession\n} = require(\"./aiSettings\");\nconst {\n AI_TASK_TRACE_SCHEMA_VERSION,\n assertPublicAiTaskTrace,\n summarizeAiTaskTrace,\n summarizeRetrievalCounts,\n summarizeToolCounts,\n summarizeValidationFlags\n} = require(\"../ai/runtime/aiTaskTrace\");\nconst {\n assertPublicAiProviderEnvelope,\n redactAiProviderText\n} = require(\"../ai/providerSafety\");\n\nconst AI_TRACE_DEBUG_SCHEMA_VERSION = \"s92.9-ai-trace-debug-view.v1\";\nconst AI_TRACE_FEEDBACK_SCHEMA_VERSION = \"s92.9-ai-trace-feedback.v1\";\n\nconst AI_TRACE_DEBUG_LIMITS = Object.freeze({\n maxTraces: 8,\n maxFeedbackLog: 24,\n maxRecentFeedback: 8,\n maxTextLength: 96\n});\n\nconst AI_TRACE_FEEDBACK_OPTIONS = Object.freeze([\n Object.freeze({ id: \"useful\", label: \"有用\" }),\n Object.freeze({ id: \"off_tone\", label: \"出戏\" }),\n Object.freeze({ id: \"forgot_context\", label: \"忘记前情\" }),\n Object.freeze({ id: \"too_short\", label: \"太短\" }),\n Object.freeze({ id: \"too_long\", label: \"太长\" }),\n Object.freeze({ id: \"role_mismatch\", label: \"不符合身份\" })\n]);\n\nconst AI_TRACE_DEBUG_VIEW_KEYS = Object.freeze([\n \"schemaVersion\",\n \"sessionId\",\n \"generatedAtTurn\",\n \"traceCount\",\n \"traces\",\n \"feedbackOptions\",\n \"recentFeedback\",\n \"safety\"\n]);\nconst AI_TRACE_FEEDBACK_OPTION_KEYS = Object.freeze([\"id\", \"label\"]);\nconst AI_TRACE_FEEDBACK_ENTRY_KEYS = Object.freeze([\n \"schemaVersion\",\n \"feedbackRecordId\",\n \"traceId\",\n \"taskType\",\n \"taskLabel\",\n \"feedbackId\",\n \"label\",\n \"recordedTurn\",\n \"createdAt\",\n \"changesGameState\"\n]);\nconst AI_TRACE_SAFETY_KEYS = Object.freeze([\n \"publicSummaryOnly\",\n \"providerTextIncluded\",\n \"localFilesIncluded\",\n \"secretValuesIncluded\",\n \"browserAdjudication\",\n \"feedbackChangesGameState\"\n]);\nconst AI_TRACE_FEEDBACK_REQUEST_KEYS = Object.freeze([\"traceId\", \"feedbackId\", \"feedback\"]);\nconst PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN =\n /\\b(rawPrompt|providerPayload|rawProviderPayload|rawPayload|fullPrompt|worldState|statePatch|rawSql|sqlite|world_sessions|safe_search_index|apiKey|baseUrl|baseURL|base_url|localPath|hiddenNotes|privateResultRefs|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY)\\b/i;\nconst PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN =\n /\\b(?:prompt|instructions|input|request|response|headers|apiKey|api[-_\\s]?key|key|token|baseURL|baseUrl|base_url|localPath|worldState|statePatch)\\s*[:=]/i;\nconst PUBLIC_TRACE_INTERNAL_REF_PATTERN = /\\bserver\\.[A-Za-z0-9_.-]+/;\nconst PUBLIC_TRACE_SECRET_TOKEN_PATTERN = /\\b(?:sk|tp)-[A-Za-z0-9_-]{6,}\\b/i;\nconst PUBLIC_TRACE_LOCAL_PATH_PATTERN =\n /(?:data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nconst TRACE_STATUS_MAP = Object.freeze({\n completed: \"ok\",\n streamed: \"ok\",\n ok: \"ok\",\n fallback: \"fallback\",\n failed: \"failed\",\n error: \"failed\",\n rejected: \"rejected\",\n running: \"running\"\n});\n\nfunction isPlainObject(value) {\n return Boolean(value && typeof value === \"object\" && !Array.isArray(value));\n}\n\nfunction asArray(value) {\n return Array.isArray(value) ? value : [];\n}\n\nfunction clampInteger(value, min, max, fallback = 0) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.trunc(parsed)));\n}\n\n/**\n * @param {unknown} value\n * @param {string} [fallback]\n * @param {number} [maxLength]\n */\nfunction cleanText(value, fallback = \"\", maxLength = AI_TRACE_DEBUG_LIMITS.maxTextLength) {\n const redacted = redactAiProviderText(value, { maxLength });\n const text = String(redacted || \"\").replace(/\\s+/g, \" \").trim();\n return text || fallback;\n}\n\n/**\n * @param {unknown} value\n * @param {string} [fallback]\n * @param {number} [maxLength]\n */\nfunction cleanToken(value, fallback = \"\", maxLength = 80) {\n const text = cleanText(value, fallback, maxLength)\n .replace(/[^A-Za-z0-9_.:-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return text || fallback;\n}\n\n/**\n * @param {number} statusCode\n * @param {string} message\n * @returns {Error & { statusCode: number }}\n */\nfunction createTraceError(statusCode, message) {\n const error = /** @type {Error & { statusCode: number }} */ (new Error(message));\n error.statusCode = statusCode;\n return error;\n}\n\nfunction cleanTraceId(value, fallback) {\n return cleanToken(value, fallback, 120);\n}\n\nfunction normalizeTraceStatus(value) {\n const status = cleanToken(value, \"failed\", 32);\n return TRACE_STATUS_MAP[status] || \"failed\";\n}\n\nfunction feedbackOptionById(feedbackId) {\n const id = cleanToken(feedbackId, \"\", 64);\n return AI_TRACE_FEEDBACK_OPTIONS.find((option) => option.id === id) || null;\n}\n\nfunction assertAllowedKeys(value, allowedKeys, label) {\n if (!isPlainObject(value)) {\n throw new Error(`AI 回声公开视图 ${label} 必须是对象。`);\n }\n for (const key of Object.keys(value)) {\n if (!allowedKeys.includes(key)) {\n throw new Error(`AI 回声公开视图含有禁止字段:${label}.${key}`);\n }\n }\n}\n\nfunction assertPublicTraceText(value, label) {\n if (value === null || value === undefined || typeof value === \"number\" || typeof value === \"boolean\") return;\n if (Array.isArray(value)) {\n value.forEach((entry, index) => assertPublicTraceText(entry, `${label}[${index}]`));\n return;\n }\n if (typeof value === \"object\") {\n for (const [key, entry] of Object.entries(value)) {\n assertPublicTraceText(entry, `${label}.${key}`);\n }\n return;\n }\n const rawText = String(value || \"\").replace(/\\s+/g, \" \").trim();\n if (\n PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN.test(rawText) ||\n PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN.test(rawText) ||\n PUBLIC_TRACE_INTERNAL_REF_PATTERN.test(rawText) ||\n PUBLIC_TRACE_SECRET_TOKEN_PATTERN.test(rawText) ||\n PUBLIC_TRACE_LOCAL_PATH_PATTERN.test(rawText)\n ) {\n throw new Error(`AI 回声公开视图含有禁止文本:${label}`);\n }\n const text = cleanText(value, \"\", 240);\n if (\n PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN.test(text) ||\n PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN.test(text) ||\n PUBLIC_TRACE_INTERNAL_REF_PATTERN.test(text) ||\n PUBLIC_TRACE_SECRET_TOKEN_PATTERN.test(text) ||\n PUBLIC_TRACE_LOCAL_PATH_PATTERN.test(text)\n ) {\n throw new Error(`AI 回声公开视图含有禁止文本:${label}`);\n }\n}\n\nfunction assertFeedbackRequestShape(request) {\n assertAllowedKeys(request, AI_TRACE_FEEDBACK_REQUEST_KEYS, \"feedbackRequest\");\n}\n\nfunction inferValidationFlags(item = {}, status = \"ok\") {\n const source = isPlainObject(item.validationFlags) ? item.validationFlags : {};\n return summarizeValidationFlags({\n schemaOk: source.schemaOk ?? status !== \"failed\",\n guardrailOk: source.guardrailOk ?? status !== \"rejected\",\n redactionOk: source.redactionOk ?? true\n });\n}\n\nfunction publicTraceFromInvocation(item = {}, index = 0) {\n if (isPlainObject(item.publicTraceSummary)) {\n const summary = summarizeAiTaskTrace(item.publicTraceSummary);\n assertPublicAiTaskTrace(summary);\n return summary;\n }\n\n const taskType = cleanToken(item.taskType, \"narrator\", 64);\n const status = normalizeTraceStatus(item.status);\n const trace = summarizeAiTaskTrace({\n schemaVersion: AI_TASK_TRACE_SCHEMA_VERSION,\n traceId: cleanTraceId(item.traceId, `ai-trace:${clampInteger(item.recordedTurn, 0, Number.MAX_SAFE_INTEGER, 0)}:${taskType}:${index}`),\n taskKind: cleanToken(item.taskKind || item.taskType, taskType, 64),\n taskType,\n promptPackId: cleanToken(item.promptPackId || item.taskType, taskType, 64),\n promptVersion: cleanToken(item.promptVersion, \"legacy\", 64),\n provider: cleanToken(item.provider, \"mock\", 64),\n model: cleanText(item.model, \"mock\", 96),\n latencyMs: clampInteger(item.latencyMs ?? item.durationMs, 0, 300000, 0),\n status,\n fallbackReason: status === \"fallback\" ? cleanToken(item.fallbackReason, \"provider_fallback\", 80) : cleanToken(item.fallbackReason, \"\", 80),\n retrievalCounts: summarizeRetrievalCounts(item.retrievalCounts),\n toolCounts: summarizeToolCounts({\n ...item.toolCounts,\n allowed: item.toolCounts?.allowed ?? item.maxToolCalls ?? item.toolBudget ?? 0,\n callCount: item.toolCounts?.callCount ?? item.toolCallCount ?? 0,\n rejected: item.toolCounts?.rejected ?? item.rejectedToolCallCount ?? 0,\n used: item.toolCounts?.used ?? item.toolCallCount ?? 0\n }),\n validationFlags: inferValidationFlags(item, status)\n });\n assertPublicAiTaskTrace(trace);\n return trace;\n}\n\nfunction readPublicTraces(worldState = {}, options = {}) {\n const { routePolicy } = resolveAiSettingsForSession(worldState, options.env || process.env);\n const invocationSummary = buildAiInvocationSummaryView(worldState, routePolicy, options.env || process.env);\n return asArray(invocationSummary.recentInvocations)\n .slice(-AI_TRACE_DEBUG_LIMITS.maxTraces)\n .map(publicTraceFromInvocation);\n}\n\nfunction normalizeFeedbackEntry(entry = {}) {\n const feedbackOption = feedbackOptionById(entry.feedbackId || entry.feedback);\n if (!feedbackOption) return null;\n const taskType = cleanToken(entry.taskType, \"narrator\", 64);\n const traceId = cleanTraceId(entry.traceId, \"\");\n if (!traceId) return null;\n return {\n schemaVersion: AI_TRACE_FEEDBACK_SCHEMA_VERSION,\n feedbackRecordId: cleanTraceId(entry.feedbackRecordId || entry.id, `ai-feedback:${traceId}:${feedbackOption.id}`),\n traceId,\n taskType,\n taskLabel: cleanText(entry.taskLabel || AI_TASK_LABELS[taskType], AI_TASK_LABELS[taskType] || \"推演调动\", 40),\n feedbackId: feedbackOption.id,\n label: feedbackOption.label,\n recordedTurn: clampInteger(entry.recordedTurn, 0, Number.MAX_SAFE_INTEGER, 0),\n createdAt: cleanText(entry.createdAt, new Date().toISOString(), 64),\n changesGameState: false\n };\n}\n\nfunction readFeedbackLog(worldState = {}) {\n const settings = ensureAiSettingsState(worldState);\n const feedbackLog = asArray(settings.observability?.traceFeedback)\n .map(normalizeFeedbackEntry)\n .filter(Boolean);\n settings.observability.traceFeedback = feedbackLog.slice(-AI_TRACE_DEBUG_LIMITS.maxFeedbackLog);\n return settings.observability.traceFeedback;\n}\n\nfunction buildAiTraceDebugView(worldState = {}, options = {}) {\n const traces = readPublicTraces(worldState, options);\n const traceIds = new Set(traces.map((trace) => trace.traceId));\n const feedbackLog = readFeedbackLog(worldState)\n .filter((entry) => traceIds.has(entry.traceId) || !traceIds.size)\n .slice(-AI_TRACE_DEBUG_LIMITS.maxRecentFeedback);\n const view = {\n schemaVersion: AI_TRACE_DEBUG_SCHEMA_VERSION,\n sessionId: cleanToken(worldState.sessionId, \"\", 120),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n traceCount: traces.length,\n traces,\n feedbackOptions: AI_TRACE_FEEDBACK_OPTIONS.map((option) => ({ ...option })),\n recentFeedback: feedbackLog,\n safety: {\n publicSummaryOnly: true,\n providerTextIncluded: false,\n localFilesIncluded: false,\n secretValuesIncluded: false,\n browserAdjudication: false,\n feedbackChangesGameState: false\n }\n };\n assertPublicAiTraceDebugView(view);\n return view;\n}\n\nfunction appendAiTraceFeedback(worldState = {}, request = {}, options = {}) {\n if (!isPlainObject(request)) {\n throw createTraceError(400, \"AI 回声反馈必须是对象。\");\n }\n try {\n assertFeedbackRequestShape(request);\n } catch (cause) {\n throw createTraceError(400, \"AI 回声反馈含有禁止字段。\");\n }\n try {\n assertPublicAiProviderEnvelope(request);\n } catch (cause) {\n throw createTraceError(400, \"AI 回声反馈含有禁止字段。\");\n }\n const traces = readPublicTraces(worldState, options);\n const traceId = cleanTraceId(request.traceId, \"\");\n const feedbackOption = feedbackOptionById(request.feedbackId || request.feedback);\n if (!traceId || !feedbackOption) {\n throw createTraceError(400, \"AI 回声反馈缺少可识别的条目或选项。\");\n }\n const trace = traces.find((item) => item.traceId === traceId);\n if (!trace) {\n throw createTraceError(404, \"AI 回声条目已过期,请先刷新。\");\n }\n\n const entry = normalizeFeedbackEntry({\n feedbackRecordId: `ai-feedback:${Date.now().toString(36)}:${feedbackOption.id}`,\n traceId,\n taskType: trace.taskType,\n taskLabel: AI_TASK_LABELS[trace.taskType] || trace.taskType,\n feedbackId: feedbackOption.id,\n recordedTurn: worldState.turnCount || 0,\n createdAt: new Date().toISOString()\n });\n const feedbackLog = readFeedbackLog(worldState);\n const settings = ensureAiSettingsState(worldState);\n settings.observability.traceFeedback = [\n ...feedbackLog,\n entry\n ].slice(-AI_TRACE_DEBUG_LIMITS.maxFeedbackLog);\n assertPublicAiProviderEnvelope(entry);\n\n return {\n entry,\n view: buildAiTraceDebugView(worldState, options)\n };\n}\n\nfunction assertPublicAiTraceDebugView(view) {\n assertPublicAiProviderEnvelope(view);\n assertAllowedKeys(view, AI_TRACE_DEBUG_VIEW_KEYS, \"aiTraceDebugView\");\n for (const trace of asArray(view.traces)) {\n assertPublicAiTaskTrace(trace);\n }\n for (const option of asArray(view.feedbackOptions)) {\n assertAllowedKeys(option, AI_TRACE_FEEDBACK_OPTION_KEYS, \"feedbackOptions\");\n if (!feedbackOptionById(option.id) || cleanText(option.label, \"\", 40) !== option.label) {\n throw new Error(\"AI 回声公开视图含有无效反馈选项。\");\n }\n assertPublicTraceText(option, \"feedbackOptions\");\n }\n for (const entry of asArray(view.recentFeedback)) {\n assertAllowedKeys(entry, AI_TRACE_FEEDBACK_ENTRY_KEYS, \"recentFeedback\");\n if (!normalizeFeedbackEntry(entry)) {\n throw new Error(\"AI 回声公开视图含有无效反馈记录。\");\n }\n assertPublicTraceText(entry, \"recentFeedback\");\n }\n assertAllowedKeys(view.safety, AI_TRACE_SAFETY_KEYS, \"safety\");\n assertPublicTraceText(view.safety, \"safety\");\n return view;\n}\n\nmodule.exports = {\n AI_TRACE_DEBUG_SCHEMA_VERSION,\n AI_TRACE_FEEDBACK_OPTIONS,\n AI_TRACE_FEEDBACK_SCHEMA_VERSION,\n appendAiTraceFeedback,\n assertPublicAiTraceDebugView,\n buildAiTraceDebugView,\n readPublicTraces\n};\n"}, "2cea4b96ceaa1ab347702b91eecce340956109b2": {"t": 1785753934.9584587, "n": 13891, "h": "659bf6d5fce1cfd5", "c": "// @ts-check\n\nconst { AI_TASK_LABELS } = require(\"./aiSettingsConfig\");\nconst {\n ensureAiSettingsState,\n buildAiInvocationSummaryView,\n resolveAiSettingsForSession\n} = require(\"./aiSettings\");\nconst {\n AI_TASK_TRACE_SCHEMA_VERSION,\n assertPublicAiTaskTrace,\n summarizeAiTaskTrace,\n summarizeRetrievalCounts,\n summarizeToolCounts,\n summarizeValidationFlags\n} = require(\"../ai/runtime/aiTaskTrace\");\nconst {\n assertPublicAiProviderEnvelope,\n redactAiProviderText\n} = require(\"../ai/providerSafety\");\n\nconst AI_TRACE_DEBUG_SCHEMA_VERSION = \"s92.9-ai-trace-debug-view.v1\";\nconst AI_TRACE_FEEDBACK_SCHEMA_VERSION = \"s92.9-ai-trace-feedback.v1\";\n\nconst AI_TRACE_DEBUG_LIMITS = Object.freeze({\n maxTraces: 8,\n maxFeedbackLog: 24,\n maxRecentFeedback: 8,\n maxTextLength: 96\n});\n\nconst AI_TRACE_FEEDBACK_OPTIONS = Object.freeze([\n Object.freeze({ id: \"useful\", label: \"有用\" }),\n Object.freeze({ id: \"off_tone\", label: \"出戏\" }),\n Object.freeze({ id: \"forgot_context\", label: \"忘记前情\" }),\n Object.freeze({ id: \"too_short\", label: \"太短\" }),\n Object.freeze({ id: \"too_long\", label: \"太长\" }),\n Object.freeze({ id: \"role_mismatch\", label: \"不符合身份\" })\n]);\n\nconst AI_TRACE_DEBUG_VIEW_KEYS = Object.freeze([\n \"schemaVersion\",\n \"sessionId\",\n \"generatedAtTurn\",\n \"traceCount\",\n \"traces\",\n \"feedbackOptions\",\n \"recentFeedback\",\n \"safety\"\n]);\nconst AI_TRACE_FEEDBACK_OPTION_KEYS = Object.freeze([\"id\", \"label\"]);\nconst AI_TRACE_FEEDBACK_ENTRY_KEYS = Object.freeze([\n \"schemaVersion\",\n \"feedbackRecordId\",\n \"traceId\",\n \"taskType\",\n \"taskLabel\",\n \"feedbackId\",\n \"label\",\n \"recordedTurn\",\n \"createdAt\",\n \"changesGameState\"\n]);\nconst AI_TRACE_SAFETY_KEYS = Object.freeze([\n \"publicSummaryOnly\",\n \"providerTextIncluded\",\n \"localFilesIncluded\",\n \"secretValuesIncluded\",\n \"browserAdjudication\",\n \"feedbackChangesGameState\"\n]);\nconst AI_TRACE_FEEDBACK_REQUEST_KEYS = Object.freeze([\"traceId\", \"feedbackId\", \"feedback\"]);\nconst PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN =\n /\\b(rawPrompt|providerPayload|rawProviderPayload|rawPayload|fullPrompt|worldState|statePatch|rawSql|sqlite|world_sessions|safe_search_index|apiKey|baseUrl|baseURL|base_url|localPath|hiddenNotes|privateResultRefs|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY)\\b/i;\nconst PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN =\n /\\b(?:prompt|instructions|input|request|response|headers|apiKey|api[-_\\s]?key|key|token|baseURL|baseUrl|base_url|localPath|worldState|statePatch)\\s*[:=]/i;\nconst PUBLIC_TRACE_INTERNAL_REF_PATTERN = /\\bserver\\.[A-Za-z0-9_.-]+/;\nconst PUBLIC_TRACE_SECRET_TOKEN_PATTERN = /\\b(?:sk|tp)-[A-Za-z0-9_-]{6,}\\b/i;\nconst PUBLIC_TRACE_LOCAL_PATH_PATTERN =\n /(?:data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nconst TRACE_STATUS_MAP = Object.freeze({\n completed: \"ok\",\n streamed: \"ok\",\n ok: \"ok\",\n fallback: \"fallback\",\n failed: \"failed\",\n error: \"failed\",\n rejected: \"rejected\",\n running: \"running\"\n});\n\nfunction isPlainObject(value) {\n return Boolean(value && typeof value === \"object\" && !Array.isArray(value));\n}\n\nfunction asArray(value) {\n return Array.isArray(value) ? value : [];\n}\n\nfunction clampInteger(value, min, max, fallback = 0) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.trunc(parsed)));\n}\n\n/**\n * @param {unknown} value\n * @param {string} [fallback]\n * @param {number} [maxLength]\n */\nfunction cleanText(value, fallback = \"\", maxLength = AI_TRACE_DEBUG_LIMITS.maxTextLength) {\n const redacted = redactAiProviderText(value, { maxLength });\n const text = String(redacted || \"\").replace(/\\s+/g, \" \").trim();\n return text || fallback;\n}\n\n/**\n * @param {unknown} value\n * @param {string} [fallback]\n * @param {number} [maxLength]\n */\nfunction cleanToken(value, fallback = \"\", maxLength = 80) {\n const text = cleanText(value, fallback, maxLength)\n .replace(/[^A-Za-z0-9_.:-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return text || fallback;\n}\n\n/**\n * @param {number} statusCode\n * @param {string} message\n * @returns {Error & { statusCode: number }}\n */\nfunction createTraceError(statusCode, message) {\n const error = /** @type {Error & { statusCode: number }} */ (new Error(message));\n error.statusCode = statusCode;\n return error;\n}\n\nfunction cleanTraceId(value, fallback) {\n return cleanToken(value, fallback, 120);\n}\n\nfunction normalizeTraceStatus(value) {\n const status = cleanToken(value, \"failed\", 32);\n return TRACE_STATUS_MAP[status] || \"failed\";\n}\n\nfunction feedbackOptionById(feedbackId) {\n const id = cleanToken(feedbackId, \"\", 64);\n return AI_TRACE_FEEDBACK_OPTIONS.find((option) => option.id === id) || null;\n}\n\nfunction assertAllowedKeys(value, allowedKeys, label) {\n if (!isPlainObject(value)) {\n throw new Error(`AI 回声公开视图 ${label} 必须是对象。`);\n }\n for (const key of Object.keys(value)) {\n if (!allowedKeys.includes(key)) {\n throw new Error(`AI 回声公开视图含有禁止字段:${label}.${key}`);\n }\n }\n}\n\nfunction assertPublicTraceText(value, label) {\n if (value === null || value === undefined || typeof value === \"number\" || typeof value === \"boolean\") return;\n if (Array.isArray(value)) {\n value.forEach((entry, index) => assertPublicTraceText(entry, `${label}[${index}]`));\n return;\n }\n if (typeof value === \"object\") {\n for (const [key, entry] of Object.entries(value)) {\n assertPublicTraceText(entry, `${label}.${key}`);\n }\n return;\n }\n const rawText = String(value || \"\").replace(/\\s+/g, \" \").trim();\n if (\n PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN.test(rawText) ||\n PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN.test(rawText) ||\n PUBLIC_TRACE_INTERNAL_REF_PATTERN.test(rawText) ||\n PUBLIC_TRACE_SECRET_TOKEN_PATTERN.test(rawText) ||\n PUBLIC_TRACE_LOCAL_PATH_PATTERN.test(rawText)\n ) {\n throw new Error(`AI 回声公开视图含有禁止文本:${label}`);\n }\n const text = cleanText(value, \"\", 240);\n if (\n PUBLIC_TRACE_TEXT_FORBIDDEN_PATTERN.test(text) ||\n PUBLIC_TRACE_SENSITIVE_ASSIGNMENT_PATTERN.test(text) ||\n PUBLIC_TRACE_INTERNAL_REF_PATTERN.test(text) ||\n PUBLIC_TRACE_SECRET_TOKEN_PATTERN.test(text) ||\n PUBLIC_TRACE_LOCAL_PATH_PATTERN.test(text)\n ) {\n throw new Error(`AI 回声公开视图含有禁止文本:${label}`);\n }\n}\n\nfunction assertFeedbackRequestShape(request) {\n assertAllowedKeys(request, AI_TRACE_FEEDBACK_REQUEST_KEYS, \"feedbackRequest\");\n}\n\nfunction inferValidationFlags(item = {}, status = \"ok\") {\n const source = isPlainObject(item.validationFlags) ? item.validationFlags : {};\n return summarizeValidationFlags({\n schemaOk: source.schemaOk ?? status !== \"failed\",\n guardrailOk: source.guardrailOk ?? status !== \"rejected\",\n redactionOk: source.redactionOk ?? true\n });\n}\n\nfunction publicTraceFromInvocation(item = {}, index = 0) {\n if (isPlainObject(item.publicTraceSummary)) {\n const summary = summarizeAiTaskTrace(item.publicTraceSummary);\n assertPublicAiTaskTrace(summary);\n return summary;\n }\n\n const taskType = cleanToken(item.taskType, \"narrator\", 64);\n const status = normalizeTraceStatus(item.status);\n const trace = summarizeAiTaskTrace({\n schemaVersion: AI_TASK_TRACE_SCHEMA_VERSION,\n traceId: cleanTraceId(item.traceId, `ai-trace:${clampInteger(item.recordedTurn, 0, Number.MAX_SAFE_INTEGER, 0)}:${taskType}:${index}`),\n taskKind: cleanToken(item.taskKind || item.taskType, taskType, 64),\n taskType,\n promptPackId: cleanToken(item.promptPackId || item.taskType, taskType, 64),\n promptVersion: cleanToken(item.promptVersion, \"legacy\", 64),\n provider: cleanToken(item.provider, \"mock\", 64),\n model: cleanText(item.model, \"mock\", 96),\n latencyMs: clampInteger(item.latencyMs ?? item.durationMs, 0, 300000, 0),\n status,\n fallbackReason: status === \"fallback\" ? cleanToken(item.fallbackReason, \"provider_fallback\", 80) : cleanToken(item.fallbackReason, \"\", 80),\n retrievalCounts: summarizeRetrievalCounts(item.retrievalCounts),\n toolCounts: summarizeToolCounts({\n ...item.toolCounts,\n allowed: item.toolCounts?.allowed ?? item.maxToolCalls ?? item.toolBudget ?? 0,\n callCount: item.toolCounts?.callCount ?? item.toolCallCount ?? 0,\n rejected: item.toolCounts?.rejected ?? item.rejectedToolCallCount ?? 0,\n used: item.toolCounts?.used ?? item.toolCallCount ?? 0\n }),\n validationFlags: inferValidationFlags(item, status)\n });\n assertPublicAiTaskTrace(trace);\n return trace;\n}\n\nfunction readPublicTraces(worldState = {}, options = {}) {\n const { routePolicy } = resolveAiSettingsForSession(worldState, options.env || process.env);\n const invocationSummary = buildAiInvocationSummaryView(worldState, routePolicy, options.env || process.env);\n return asArray(invocationSummary.recentInvocations)\n .slice(-AI_TRACE_DEBUG_LIMITS.maxTraces)\n .map(publicTraceFromInvocation);\n}\n\nfunction normalizeFeedbackEntry(entry = {}) {\n const feedbackOption = feedbackOptionById(entry.feedbackId || entry.feedback);\n if (!feedbackOption) return null;\n const taskType = cleanToken(entry.taskType, \"narrator\", 64);\n const traceId = cleanTraceId(entry.traceId, \"\");\n if (!traceId) return null;\n return {\n schemaVersion: AI_TRACE_FEEDBACK_SCHEMA_VERSION,\n feedbackRecordId: cleanTraceId(entry.feedbackRecordId || entry.id, `ai-feedback:${traceId}:${feedbackOption.id}`),\n traceId,\n taskType,\n taskLabel: cleanText(entry.taskLabel || AI_TASK_LABELS[taskType], AI_TASK_LABELS[taskType] || \"推演调动\", 40),\n feedbackId: feedbackOption.id,\n label: feedbackOption.label,\n recordedTurn: clampInteger(entry.recordedTurn, 0, Number.MAX_SAFE_INTEGER, 0),\n createdAt: cleanText(entry.createdAt, new Date().toISOString(), 64),\n changesGameState: false\n };\n}\n\nfunction readFeedbackLog(worldState = {}) {\n const settings = ensureAiSettingsState(worldState);\n const feedbackLog = asArray(settings.observability?.traceFeedback)\n .map(normalizeFeedbackEntry)\n .filter(Boolean);\n settings.observability.traceFeedback = feedbackLog.slice(-AI_TRACE_DEBUG_LIMITS.maxFeedbackLog);\n return settings.observability.traceFeedback;\n}\n\nfunction buildAiTraceDebugView(worldState = {}, options = {}) {\n const traces = readPublicTraces(worldState, options);\n const traceIds = new Set(traces.map((trace) => trace.traceId));\n const feedbackLog = readFeedbackLog(worldState)\n .filter((entry) => traceIds.has(entry.traceId) || !traceIds.size)\n .slice(-AI_TRACE_DEBUG_LIMITS.maxRecentFeedback);\n const view = {\n schemaVersion: AI_TRACE_DEBUG_SCHEMA_VERSION,\n sessionId: cleanToken(worldState.sessionId, \"\", 120),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n traceCount: traces.length,\n traces,\n feedbackOptions: AI_TRACE_FEEDBACK_OPTIONS.map((option) => ({ ...option })),\n recentFeedback: feedbackLog,\n safety: {\n publicSummaryOnly: true,\n providerTextIncluded: false,\n localFilesIncluded: false,\n secretValuesIncluded: false,\n browserAdjudication: false,\n feedbackChangesGameState: false\n }\n };\n assertPublicAiTraceDebugView(view);\n return view;\n}\n\nfunction appendAiTraceFeedback(worldState = {}, request = {}, options = {}) {\n if (!isPlainObject(request)) {\n throw createTraceError(400, \"AI 回声反馈必须是对象。\");\n }\n try {\n assertFeedbackRequestShape(request);\n } catch (cause) {\n throw createTraceError(400, \"AI 回声反馈含有禁止字段。\");\n }\n try {\n assertPublicAiProviderEnvelope(request);\n } catch (cause) {\n throw createTraceError(400, \"AI 回声反馈含有禁止字段。\");\n }\n const traces = readPublicTraces(worldState, options);\n const traceId = cleanTraceId(request.traceId, \"\");\n const feedbackOption = feedbackOptionById(request.feedbackId || request.feedback);\n if (!traceId || !feedbackOption) {\n throw createTraceError(400, \"AI 回声反馈缺少可识别的条目或选项。\");\n }\n const trace = traces.find((item) => item.traceId === traceId);\n if (!trace) {\n throw createTraceError(404, \"AI 回声条目已过期,请先刷新。\");\n }\n\n const entry = normalizeFeedbackEntry({\n feedbackRecordId: `ai-feedback:${Date.now().toString(36)}:${feedbackOption.id}`,\n traceId,\n taskType: trace.taskType,\n taskLabel: AI_TASK_LABELS[trace.taskType] || trace.taskType,\n feedbackId: feedbackOption.id,\n recordedTurn: worldState.turnCount || 0,\n createdAt: new Date().toISOString()\n });\n const feedbackLog = readFeedbackLog(worldState);\n const settings = ensureAiSettingsState(worldState);\n settings.observability.traceFeedback = [\n ...feedbackLog,\n entry\n ].slice(-AI_TRACE_DEBUG_LIMITS.maxFeedbackLog);\n assertPublicAiProviderEnvelope(entry);\n\n return {\n entry,\n view: buildAiTraceDebugView(worldState, options)\n };\n}\n\nfunction assertPublicAiTraceDebugView(view) {\n assertPublicAiProviderEnvelope(view);\n assertAllowedKeys(view, AI_TRACE_DEBUG_VIEW_KEYS, \"aiTraceDebugView\");\n for (const trace of asArray(view.traces)) {\n assertPublicAiTaskTrace(trace);\n }\n for (const option of asArray(view.feedbackOptions)) {\n assertAllowedKeys(option, AI_TRACE_FEEDBACK_OPTION_KEYS, \"feedbackOptions\");\n if (!feedbackOptionById(option.id) || cleanText(option.label, \"\", 40) !== option.label) {\n throw new Error(\"AI 回声公开视图含有无效反馈选项。\");\n }\n assertPublicTraceText(option, \"feedbackOptions\");\n }\n for (const entry of asArray(view.recentFeedback)) {\n assertAllowedKeys(entry, AI_TRACE_FEEDBACK_ENTRY_KEYS, \"recentFeedback\");\n if (!normalizeFeedbackEntry(entry)) {\n throw new Error(\"AI 回声公开视图含有无效反馈记录。\");\n }\n assertPublicTraceText(entry, \"recentFeedback\");\n }\n assertAllowedKeys(view.safety, AI_TRACE_SAFETY_KEYS, \"safety\");\n assertPublicTraceText(view.safety, \"safety\");\n return view;\n}\n\nmodule.exports = {\n AI_TRACE_DEBUG_SCHEMA_VERSION,\n AI_TRACE_FEEDBACK_OPTIONS,\n AI_TRACE_FEEDBACK_SCHEMA_VERSION,\n appendAiTraceFeedback,\n assertPublicAiTraceDebugView,\n buildAiTraceDebugView,\n readPublicTraces\n};\n"}}, "scripts/frontendAssetQa.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595220.306664, "n": 26410, "h": "d5b91793a2342f80", "c": "const crypto = require(\"node:crypto\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst manifestPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"ink-ui-manifest.json\");\nconst defaultReportPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"asset-qa-report-v1.json\");\nconst previewPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"asset-qa-preview.html\");\n\nconst forbiddenSecretOrLocalPath =\n /(OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/]|file:\\/\\/|https?:\\/\\/|data:|data[\\\\/](?:sessions|audit))/i;\nconst forbiddenAssetValue =\n /(world_sessions|prompt_retrieval_index|event_log|hiddenNotes|hiddenIntent|raw[_ -]?(?:table|prompt|provider|audit|coordinate)|完整 prompt 原文:)/i;\n\nconst compositeBackgrounds = Object.freeze([\n { id: \"paper\", color: \"#f5f0e6\", label: \"宣纸底\" },\n { id: \"dark\", color: \"#201c16\", label: \"深色底\" }\n]);\n\nconst pixelThresholds = Object.freeze({\n borderSampleWidthPx: 2,\n hardAlphaJumpThreshold: 220,\n maxHighSaturationGreenOrMagentaPixels: 180,\n maxBorderVisibleAlphaPixels: 1800\n});\n\nfunction parseArgs(argv) {\n const options = {\n write: false,\n check: false,\n pixel: false,\n browserPath: null,\n reportPath: defaultReportPath\n };\n\n for (let index = 0; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--write\") options.write = true;\n else if (arg === \"--check\") options.check = true;\n else if (arg === \"--pixel\") options.pixel = true;\n else if (arg === \"--browser\") {\n options.browserPath = argv[index + 1];\n index += 1;\n } else if (arg === \"--report\") {\n options.reportPath = path.resolve(rootDir, argv[index + 1]);\n index += 1;\n } else if (arg === \"--help\" || arg === \"-h\") {\n printHelp();\n process.exit(0);\n } else {\n throw new Error(`Unknown argument: ${arg}`);\n }\n }\n\n if (!options.write && !options.check) options.check = true;\n return options;\n}\n\nfunction printHelp() {\n console.log(`Usage:\n node scripts/frontendAssetQa.js --check\n node scripts/frontendAssetQa.js --write --pixel [--browser <path>]\n\nOptions:\n --check Validate the committed S73.9 QA report against the current manifest.\n --write Regenerate public/assets/ui/asset-qa-report-v1.json.\n --pixel Use the existing Playwright/Chrome toolchain to sample transparent asset pixels.\n --browser Optional Chrome/Edge executable path for --pixel.\n --report Optional report path. Defaults to public/assets/ui/asset-qa-report-v1.json.`);\n}\n\nfunction readJson(filePath) {\n return JSON.parse(fs.readFileSync(filePath, \"utf8\"));\n}\n\nfunction sha256Text(text) {\n return crypto.createHash(\"sha256\").update(text).digest(\"hex\");\n}\n\nfunction sha256File(filePath) {\n return crypto.createHash(\"sha256\").update(fs.readFileSync(filePath)).digest(\"hex\");\n}\n\nfunction toProjectPath(filePath) {\n return path.relative(rootDir, filePath).replace(/\\\\/g, \"/\");\n}\n\nfunction assertSafeUiAssetPath(assetPath, fieldName, issues) {\n if (assetPath == null || assetPath === \"不适用\") return;\n if (typeof assetPath !== \"string\") {\n issues.push(errorIssue(fieldName, \"路径字段必须是字符串\"));\n return;\n }\n if (!assetPath.startsWith(\"/assets/ui/\")) {\n issues.push(errorIssue(fieldName, \"路径必须位于 /assets/ui/ 下\"));\n }\n if (assetPath.includes(\"..\")) issues.push(errorIssue(fieldName, \"路径不得包含父目录跳转\"));\n if (/^https?:\\/\\//i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用远程 URL\"));\n if (/^file:\\/\\//i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用 file URL\"));\n if (/^data:/i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用 data URL\"));\n if (forbiddenSecretOrLocalPath.test(assetPath)) {\n issues.push(errorIssue(fieldName, \"路径包含本地路径、密钥或敏感存储形状\"));\n }\n}\n\nfunction resolveUiAssetPath(assetPath) {\n return path.join(rootDir, \"public\", assetPath.replace(/^\\//, \"\"));\n}\n\nfunction readUInt24LE(buffer, offset) {\n return buffer[offset] + (buffer[offset + 1] << 8) + (buffer[offset + 2] << 16);\n}\n\nfunction readWebpInfo(buffer) {\n if (buffer.toString(\"ascii\", 0, 4) !== \"RIFF\" || buffer.toString(\"ascii\", 8, 12) !== \"WEBP\") {\n throw new Error(\"Unsupported WebP container\");\n }\n\n let offset = 12;\n while (offset + 8 <= buffer.length) {\n const chunk = buffer.toString(\"ascii\", offset, offset + 4);\n const size = buffer.readUInt32LE(offset + 4);\n const data = offset + 8;\n if (chunk === \"VP8X\") {\n const flags = buffer.readUInt8(data);\n return {\n width: readUInt24LE(buffer, data + 4) + 1,\n height: readUInt24LE(buffer, data + 7) + 1,\n alpha: Boolean(flags & 0x10)\n };\n }\n if (chunk === \"VP8 \") {\n return {\n width: buffer.readUInt16LE(data + 6) & 0x3fff,\n height: buffer.readUInt16LE(data + 8) & 0x3fff,\n alpha: false\n };\n }\n if (chunk === \"VP8L\") {\n const b0 = buffer[data + 1];\n const b1 = buffer[data + 2];\n const b2 = buffer[data + 3];\n const b3 = buffer[data + 4];\n return {\n width: 1 + (((b1 & 0x3f) << 8) | b0),\n height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),\n alpha: true\n };\n }\n offset += 8 + size + (size % 2);\n }\n throw new Error(\"Unsupported WebP encoding\");\n}\n\nfunction readImageInfo(filePath) {\n if (!filePath.endsWith(\".webp\")) throw new Error(`Unsupported UI asset format: ${filePath}`);\n return readWebpInfo(fs.readFileSync(filePath));\n}\n\nfunction errorIssue(target, message) {\n return { severity: \"error\", target, message };\n}\n\nfunction warningIssue(target, message) {\n return { severity: \"warning\", target, message };\n}\n\nfunction collectForbiddenValueIssues(value, label, issues) {\n if (value == null) return;\n if (typeof value === \"string\") {\n if (forbiddenSecretOrLocalPath.test(value)) {\n issues.push(errorIssue(label, \"字段包含密钥、本地路径、远程 URL 或敏感存储形状\"));\n }\n if (forbiddenAssetValue.test(value)) {\n issues.push(errorIssue(label, \"字段包含 raw/hidden/prompt/provider/audit 禁用内容\"));\n }\n return;\n }\n if (Array.isArray(value)) {\n value.forEach((item, index) => collectForbiddenValueIssues(item, `${label}[${index}]`, issues));\n return;\n }\n if (typeof value === \"object\") {\n for (const [key, nestedValue] of Object.entries(value)) {\n collectForbiddenValueIssues(nestedValue, `${label}.${key}`, issues);\n }\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const explicitPath = options.browserPath || process.env.BROWSER_EXECUTABLE_PATH;\n if (explicitPath) {\n if (fs.existsSync(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n const candidate = getDefaultBrowserCandidates().find((candidatePath) => fs.existsSync(candidatePath));\n if (candidate) return candidate;\n throw new Error(\"No Chrome/Edge executable found. Set BROWSER_EXECUTABLE_PATH or pass --browser <path>.\");\n}\n\nasync function analyzeTransparentPixels(assets, options) {\n const transparentAssets = assets.filter((asset) => asset.transparent);\n if (transparentAssets.length === 0) {\n return { enabled: false, reason: \"no transparent assets\", assets: {} };\n }\n\n const { chromium } = require(\"playwright-core\");\n const browserPath = resolveBrowserExecutable(options);\n const browser = await chromium.launch({ executablePath: browserPath, headless: true });\n const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });\n const results = {};\n\n try {\n for (const asset of transparentAssets) {\n const absolutePath = resolveUiAssetPath(asset.path);\n const dataUrl = `data:image/webp;base64,${fs.readFileSync(absolutePath).toString(\"base64\")}`;\n results[asset.id] = await page.evaluate(\n async ({ dataUrl: source, thresholds, backgrounds }) => {\n function loadImage(src) {\n return new Promise((resolve, reject) => {\n const img = new Image();\n img.onload = () => resolve(img);\n img.onerror = () => reject(new Error(\"image decode failed\"));\n img.src = src;\n });\n }\n\n function parseHexColor(hex) {\n const value = hex.replace(\"#\", \"\");\n return {\n r: parseInt(value.slice(0, 2), 16),\n g: parseInt(value.slice(2, 4), 16),\n b: parseInt(value.slice(4, 6), 16)\n };\n }\n\n function luma(r, g, b) {\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n }\n\n const img = await loadImage(source);\n const canvas = document.createElement(\"canvas\");\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n ctx.drawImage(img, 0, 0);\n const image = ctx.getImageData(0, 0, canvas.width, canvas.height);\n const data = image.data;\n const totalPixels = canvas.width * canvas.height;\n let visibleAlphaPixels = 0;\n let borderVisibleAlphaPixels = 0;\n let highSaturationGreenOrMagentaPixels = 0;\n let hardAlphaJumpPixels = 0;\n const composite = {};\n\n for (const background of backgrounds) {\n composite[background.id] = {\n minLuma: 255,\n maxLuma: 0,\n averageLuma: 0\n };\n }\n\n for (let y = 0; y < canvas.height; y += 1) {\n for (let x = 0; x < canvas.width; x += 1) {\n const offset = (y * canvas.width + x) * 4;\n const r = data[offset];\n const g = data[offset + 1];\n const b = data[offset + 2];\n const a = data[offset + 3];\n const visible = a > 16;\n if (visible) {\n visibleAlphaPixels += 1;\n if (\n x < thresholds.borderSampleWidthPx ||\n y < thresholds.borderSampleWidthPx ||\n x >= canvas.width - thresholds.borderSampleWidthPx ||\n y >= canvas.height - thresholds.borderSampleWidthPx\n ) {\n borderVisibleAlphaPixels += 1;\n }\n const greenEdge = g > 140 && g > r * 1.35 && g > b * 1.15;\n const magentaEdge = r > 130 && b > 115 && Math.abs(r - b) < 90 && g < Math.min(r, b) * 0.65;\n if (greenEdge || magentaEdge) highSaturationGreenOrMagentaPixels += 1;\n }\n\n if (x + 1 < canvas.width) {\n const rightAlpha = data[offset + 7];\n if (Math.abs(a - rightAlpha) >= thresholds.hardAlphaJumpThreshold) hardAlphaJumpPixels += 1;\n }\n if (y + 1 < canvas.height) {\n const downAlpha = data[offset + canvas.width * 4 + 3];\n if (Math.abs(a - downAlpha) >= thresholds.hardAlphaJumpThreshold) hardAlphaJumpPixels += 1;\n }\n\n for (const background of backgrounds) {\n const bg = parseHexColor(background.color);\n const alpha = a / 255;\n const cr = r * alpha + bg.r * (1 - alpha);\n const cg = g * alpha + bg.g * (1 - alpha);\n const cb = b * alpha + bg.b * (1 - alpha);\n const lum = luma(cr, cg, cb);\n const summary = composite[background.id];\n summary.minLuma = Math.min(summary.minLuma, Math.round(lum));\n summary.maxLuma = Math.max(summary.maxLuma, Math.round(lum));\n summary.averageLuma += lum;\n }\n }\n }\n\n for (const background of backgrounds) {\n composite[background.id].averageLuma = Number((composite[background.id].averageLuma / totalPixels).toFixed(2));\n }\n\n return {\n width: canvas.width,\n height: canvas.height,\n visibleAlphaPixels,\n visibleAlphaRatio: Number((visibleAlphaPixels / totalPixels).toFixed(4)),\n borderVisibleAlphaPixels,\n highSaturationGreenOrMagentaPixels,\n hardAlphaJumpPixels,\n composite\n };\n },\n { dataUrl, thresholds: pixelThresholds, backgrounds: compositeBackgrounds }\n );\n }\n } finally {\n await browser.close();\n }\n\n return {\n enabled: true,\n browserExecutable: browserPath,\n assets: results\n };\n}\n\nfunction findSidecarMetrics(asset) {\n if (asset.phase === \"S73.4\") {\n const sidecarPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"home\", \"home-transparency-qa-v1.json\");\n if (!fs.existsSync(sidecarPath)) return null;\n const sidecar = readJson(sidecarPath);\n const entry = sidecar.assets.find((candidate) => candidate.id === asset.id);\n if (!entry) return null;\n return {\n source: \"home-transparency-qa-v1\",\n path: toProjectPath(sidecarPath),\n sha256: entry.sha256,\n metrics: entry.metrics\n };\n }\n\n if (asset.phase === \"S73.8\" && asset.performance && typeof asset.performance.visibleAlphaRatio === \"number\") {\n return {\n source: \"manifest-performance-alpha\",\n metrics: {\n visibleAlphaRatio: asset.performance.visibleAlphaRatio,\n borderVisibleAlphaPixels: asset.performance.borderVisibleAlphaPixels\n }\n };\n }\n\n return null;\n}\n\nfunction buildAssetRecord(asset, manifest, pixelAnalysis, issues) {\n const assetIssues = [];\n collectForbiddenValueIssues(asset, `assets.${asset.id}`, assetIssues);\n assertSafeUiAssetPath(asset.path, `${asset.id}.path`, assetIssues);\n assertSafeUiAssetPath(asset.thumbnailPath, `${asset.id}.thumbnailPath`, assetIssues);\n assertSafeUiAssetPath(asset.lowResPlaceholderPath, `${asset.id}.lowResPlaceholderPath`, assetIssues);\n\n const filePath = resolveUiAssetPath(asset.path);\n const thumbnailPath = resolveUiAssetPath(asset.thumbnailPath);\n const fallbackIds = new Set((manifest.fallbackCatalog || []).map((fallback) => fallback.id));\n const runtimeUsable = (manifest.runtimeUsableReviewStatuses || []).includes(asset.reviewStatus);\n\n const files = {\n image: {\n path: asset.path,\n exists: fs.existsSync(filePath)\n },\n thumbnail: {\n path: asset.thumbnailPath,\n exists: asset.thumbnailPath ? fs.existsSync(thumbnailPath) : false\n }\n };\n\n let imageInfo = null;\n if (files.image.exists) {\n files.image.bytes = fs.statSync(filePath).size;\n files.image.sha256 = sha256File(filePath);\n imageInfo = readImageInfo(filePath);\n files.image.dimensions = { width: imageInfo.width, height: imageInfo.height };\n files.image.alpha = imageInfo.alpha;\n } else {\n assetIssues.push(errorIssue(asset.id, \"素材文件不存在\"));\n }\n\n if (files.thumbnail.exists) {\n files.thumbnail.bytes = fs.statSync(thumbnailPath).size;\n files.thumbnail.sha256 = sha256File(thumbnailPath);\n files.thumbnail.dimensions = readImageInfo(thumbnailPath);\n } else {\n assetIssues.push(errorIssue(asset.id, \"缩略图文件不存在\"));\n }\n\n if (imageInfo) {\n if (imageInfo.width !== asset.dimensions.width || imageInfo.height !== asset.dimensions.height) {\n assetIssues.push(errorIssue(asset.id, \"manifest 尺寸与图片实际尺寸不一致\"));\n }\n if (imageInfo.alpha !== asset.transparent) {\n assetIssues.push(errorIssue(asset.id, \"manifest transparent 与图片 alpha 不一致\"));\n }\n }\n\n if (!runtimeUsable) assetIssues.push(errorIssue(asset.id, \"active assets 必须是 runtime 可用审核状态\"));\n if (asset.category !== \"fallback\" && !fallbackIds.has(asset.fallbackRef)) {\n assetIssues.push(errorIssue(asset.id, \"fallbackRef 未指向 fallbackCatalog\"));\n }\n if (files.image.bytes && asset.performance?.bytes !== files.image.bytes) {\n assetIssues.push(errorIssue(asset.id, \"performance.bytes 与文件大小不一致\"));\n }\n if (files.thumbnail.bytes && asset.performance?.thumbnailBytes !== files.thumbnail.bytes) {\n assetIssues.push(errorIssue(asset.id, \"performance.thumbnailBytes 与文件大小不一致\"));\n }\n if (files.image.bytes && asset.performance?.targetMaxBytes && files.image.bytes > asset.performance.targetMaxBytes) {\n assetIssues.push(errorIssue(asset.id, \"素材超过 targetMaxBytes\"));\n }\n if (\n files.thumbnail.bytes &&\n asset.performance?.thumbnailTargetMaxBytes &&\n files.thumbnail.bytes > asset.performance.thumbnailTargetMaxBytes\n ) {\n assetIssues.push(errorIssue(asset.id, \"缩略图超过 thumbnailTargetMaxBytes\"));\n }\n\n const transparentQa = {\n required: Boolean(asset.transparent),\n backgrounds: asset.transparent ? compositeBackgrounds : [],\n sidecar: asset.transparent ? findSidecarMetrics(asset) : null,\n pixel: asset.transparent ? pixelAnalysis.assets[asset.id] || null : null\n };\n\n if (asset.transparent && !transparentQa.pixel) {\n assetIssues.push(errorIssue(asset.id, \"透明素材缺少 S73.9 pixel QA 指标\"));\n }\n\n if (transparentQa.pixel) {\n const pixel = transparentQa.pixel;\n if (pixel.highSaturationGreenOrMagentaPixels > pixelThresholds.maxHighSaturationGreenOrMagentaPixels) {\n assetIssues.push(warningIssue(asset.id, \"透明素材存在较多高饱和绿/紫像素,需人工查看预览\"));\n }\n if (pixel.borderVisibleAlphaPixels > pixelThresholds.maxBorderVisibleAlphaPixels) {\n assetIssues.push(warningIssue(asset.id, \"透明素材边界可见 alpha 较多,需检查是否为矩形切口或合法边缘\"));\n }\n }\n\n if (asset.category === \"portrait\") {\n const placeholderPath = resolveUiAssetPath(asset.lowResPlaceholderPath);\n files.lowResPlaceholder = {\n path: asset.lowResPlaceholderPath,\n exists: fs.existsSync(placeholderPath)\n };\n if (!files.lowResPlaceholder.exists) {\n assetIssues.push(errorIssue(asset.id, \"立绘缺少低清占位\"));\n } else {\n files.lowResPlaceholder.bytes = fs.statSync(placeholderPath).size;\n files.lowResPlaceholder.sha256 = sha256File(placeholderPath);\n files.lowResPlaceholder.dimensions = readImageInfo(placeholderPath);\n if (asset.performance?.lowResPlaceholderBytes !== files.lowResPlaceholder.bytes) {\n assetIssues.push(errorIssue(asset.id, \"低清占位大小与 performance 不一致\"));\n }\n }\n if (asset.lazyLoad?.allowEagerLoad !== false) {\n assetIssues.push(errorIssue(asset.id, \"立绘不得允许 eager load\"));\n }\n if (!asset.portraitRef || asset.portraitRef !== asset.id) {\n assetIssues.push(errorIssue(asset.id, \"立绘必须以 id 作为 portraitRef\"));\n }\n }\n\n issues.push(...assetIssues);\n return {\n id: asset.id,\n phase: asset.phase,\n category: asset.category,\n subcategory: asset.subcategory,\n usage: asset.usage,\n path: asset.path,\n thumbnailPath: asset.thumbnailPath,\n fallbackRef: asset.fallbackRef,\n reviewStatus: asset.reviewStatus,\n runtimeUsable,\n dimensions: asset.dimensions,\n transparent: asset.transparent,\n files,\n transparentQa,\n issues: assetIssues\n };\n}\n\nasync function buildReport(options) {\n const manifestText = fs.readFileSync(manifestPath, \"utf8\");\n const manifest = JSON.parse(manifestText);\n const issues = [];\n collectForbiddenValueIssues(manifest.assets, \"manifest.assets\", issues);\n\n const assets = manifest.assets || [];\n const pixelAnalysis = options.pixel\n ? await analyzeTransparentPixels(assets, options)\n : { enabled: false, reason: \"run with --pixel to refresh transparent pixel metrics\", assets: {} };\n\n const assetReports = assets.map((asset) => buildAssetRecord(asset, manifest, pixelAnalysis, issues));\n const summary = assetReports.reduce(\n (acc, asset) => {\n acc.totalAssets += 1;\n acc.transparentAssets += asset.transparent ? 1 : 0;\n acc.byPhase[asset.phase] = (acc.byPhase[asset.phase] || 0) + 1;\n acc.byCategory[asset.category] = (acc.byCategory[asset.category] || 0) + 1;\n acc.byReviewStatus[asset.reviewStatus] = (acc.byReviewStatus[asset.reviewStatus] || 0) + 1;\n return acc;\n },\n { totalAssets: 0, transparentAssets: 0, byPhase: {}, byCategory: {}, byReviewStatus: {} }\n );\n\n summary.errorCount = issues.filter((issue) => issue.severity === \"error\").length;\n summary.warningCount = issues.filter((issue) => issue.severity === \"warning\").length;\n\n return {\n schemaVersion: 1,\n phase: \"S73.9\",\n generatedAt: new Date().toISOString(),\n manifestRef: toProjectPath(manifestPath),\n manifestSha256: sha256Text(manifestText),\n previewRef: toProjectPath(previewPath),\n compositeBackgrounds,\n thresholds: pixelThresholds,\n pixelAnalysis: {\n enabled: pixelAnalysis.enabled,\n browserExecutable: pixelAnalysis.browserExecutable ? path.basename(pixelAnalysis.browserExecutable) : null,\n reason: pixelAnalysis.reason || null\n },\n summary,\n assets: assetReports,\n issues\n };\n}\n\nfunction validateCommittedReport(reportPath) {\n if (!fs.existsSync(reportPath)) {\n throw new Error(`Missing QA report: ${toProjectPath(reportPath)}`);\n }\n const manifestText = fs.readFileSync(manifestPath, \"utf8\");\n const manifest = JSON.parse(manifestText);\n const report = readJson(reportPath);\n\n const failures = [];\n if (report.schemaVersion !== 1) failures.push(\"schemaVersion must be 1\");\n if (report.phase !== \"S73.9\") failures.push(\"phase must be S73.9\");\n if (report.manifestSha256 !== sha256Text(manifestText)) failures.push(\"manifestSha256 is stale\");\n if (!fs.existsSync(previewPath)) failures.push(\"asset QA preview page is missing\");\n\n const manifestAssets = manifest.assets || [];\n const assetsById = new Map((report.assets || []).map((asset) => [asset.id, asset]));\n if ((report.assets || []).length !== manifestAssets.length) {\n failures.push(\"report.assets length does not match manifest assets length\");\n }\n for (const entry of report.assets || []) {\n if (!manifestAssets.some((asset) => asset.id === entry.id)) {\n failures.push(`QA report has stale asset ${entry.id}`);\n }\n }\n\n for (const asset of manifestAssets) {\n const entry = assetsById.get(asset.id);\n if (!entry) {\n failures.push(`QA report missing asset ${asset.id}`);\n continue;\n }\n if (entry.reviewStatus !== asset.reviewStatus) failures.push(`${asset.id} reviewStatus is stale`);\n if (entry.path !== asset.path) failures.push(`${asset.id} path is stale`);\n if (entry.thumbnailPath !== asset.thumbnailPath) failures.push(`${asset.id} thumbnailPath is stale`);\n if (asset.transparent && !entry.transparentQa?.pixel) {\n failures.push(`${asset.id} missing transparent pixel QA`);\n }\n if (entry.files?.image?.exists !== true) failures.push(`${asset.id} image file missing in QA report`);\n if (entry.files?.image?.sha256 && entry.files.image.sha256 !== sha256File(resolveUiAssetPath(asset.path))) {\n failures.push(`${asset.id} image sha256 is stale`);\n }\n if (entry.files?.image?.bytes !== fs.statSync(resolveUiAssetPath(asset.path)).size) {\n failures.push(`${asset.id} image bytes are stale`);\n }\n if (asset.thumbnailPath) {\n const thumbnailPath = resolveUiAssetPath(asset.thumbnailPath);\n if (entry.files?.thumbnail?.exists !== true) failures.push(`${asset.id} thumbnail file missing in QA report`);\n if (entry.files?.thumbnail?.sha256 !== sha256File(thumbnailPath)) {\n failures.push(`${asset.id} thumbnail sha256 is stale`);\n }\n if (entry.files?.thumbnail?.bytes !== fs.statSync(thumbnailPath).size) {\n failures.push(`${asset.id} thumbnail bytes are stale`);\n }\n }\n if (asset.category === \"portrait\" && asset.lowResPlaceholderPath) {\n const placeholderPath = resolveUiAssetPath(asset.lowResPlaceholderPath);\n if (entry.files?.lowResPlaceholder?.exists !== true) {\n failures.push(`${asset.id} low-res placeholder file missing in QA report`);\n }\n if (entry.files?.lowResPlaceholder?.sha256 !== sha256File(placeholderPath)) {\n failures.push(`${asset.id} low-res placeholder sha256 is stale`);\n }\n if (entry.files?.lowResPlaceholder?.bytes !== fs.statSync(placeholderPath).size) {\n failures.push(`${asset.id} low-res placeholder bytes are stale`);\n }\n }\n }\n\n if (report.summary?.totalAssets !== manifestAssets.length) failures.push(\"summary.totalAssets is stale\");\n const reportErrors = (report.issues || []).filter((issue) => issue.severity === \"error\");\n if (reportErrors.length > 0) {\n failures.push(`QA report contains ${reportErrors.length} blocking error(s)`);\n }\n\n if (failures.length > 0) {\n throw new Error(`Frontend asset QA failed:\\n- ${failures.join(\"\\n- \")}`);\n }\n\n return report;\n}\n\nasync function main() {\n const options = parseArgs(process.argv.slice(2));\n if (options.write) {\n const report = await buildReport(options);\n fs.writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\\n`);\n console.log(\n `Wrote ${toProjectPath(options.reportPath)} (${report.summary.totalAssets} assets, ${report.summary.transparentAssets} transparent, ${report.summary.errorCount} errors, ${report.summary.warningCount} warnings)`\n );\n if (report.summary.errorCount > 0) process.exitCode = 1;\n }\n\n if (options.check) {\n const report = validateCommittedReport(options.reportPath);\n console.log(\n `S73.9 frontend asset QA ok: ${report.summary.totalAssets} assets, ${report.summary.transparentAssets} transparent assets, ${report.summary.warningCount} visual warning(s).`\n );\n }\n}\n\nif (require.main === module) {\n main().catch((error) => {\n console.error(error.stack || error.message);\n process.exit(1);\n });\n}\n\nmodule.exports = {\n buildReport,\n validateCommittedReport,\n readWebpInfo\n};\n"}, "a51139a5c78251c3085ecf8b5e6e641e0badbd83": {"t": 1785753932.3178666, "n": 26410, "h": "d5b91793a2342f80", "c": "const crypto = require(\"node:crypto\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst manifestPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"ink-ui-manifest.json\");\nconst defaultReportPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"asset-qa-report-v1.json\");\nconst previewPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"asset-qa-preview.html\");\n\nconst forbiddenSecretOrLocalPath =\n /(OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/]|file:\\/\\/|https?:\\/\\/|data:|data[\\\\/](?:sessions|audit))/i;\nconst forbiddenAssetValue =\n /(world_sessions|prompt_retrieval_index|event_log|hiddenNotes|hiddenIntent|raw[_ -]?(?:table|prompt|provider|audit|coordinate)|完整 prompt 原文:)/i;\n\nconst compositeBackgrounds = Object.freeze([\n { id: \"paper\", color: \"#f5f0e6\", label: \"宣纸底\" },\n { id: \"dark\", color: \"#201c16\", label: \"深色底\" }\n]);\n\nconst pixelThresholds = Object.freeze({\n borderSampleWidthPx: 2,\n hardAlphaJumpThreshold: 220,\n maxHighSaturationGreenOrMagentaPixels: 180,\n maxBorderVisibleAlphaPixels: 1800\n});\n\nfunction parseArgs(argv) {\n const options = {\n write: false,\n check: false,\n pixel: false,\n browserPath: null,\n reportPath: defaultReportPath\n };\n\n for (let index = 0; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--write\") options.write = true;\n else if (arg === \"--check\") options.check = true;\n else if (arg === \"--pixel\") options.pixel = true;\n else if (arg === \"--browser\") {\n options.browserPath = argv[index + 1];\n index += 1;\n } else if (arg === \"--report\") {\n options.reportPath = path.resolve(rootDir, argv[index + 1]);\n index += 1;\n } else if (arg === \"--help\" || arg === \"-h\") {\n printHelp();\n process.exit(0);\n } else {\n throw new Error(`Unknown argument: ${arg}`);\n }\n }\n\n if (!options.write && !options.check) options.check = true;\n return options;\n}\n\nfunction printHelp() {\n console.log(`Usage:\n node scripts/frontendAssetQa.js --check\n node scripts/frontendAssetQa.js --write --pixel [--browser <path>]\n\nOptions:\n --check Validate the committed S73.9 QA report against the current manifest.\n --write Regenerate public/assets/ui/asset-qa-report-v1.json.\n --pixel Use the existing Playwright/Chrome toolchain to sample transparent asset pixels.\n --browser Optional Chrome/Edge executable path for --pixel.\n --report Optional report path. Defaults to public/assets/ui/asset-qa-report-v1.json.`);\n}\n\nfunction readJson(filePath) {\n return JSON.parse(fs.readFileSync(filePath, \"utf8\"));\n}\n\nfunction sha256Text(text) {\n return crypto.createHash(\"sha256\").update(text).digest(\"hex\");\n}\n\nfunction sha256File(filePath) {\n return crypto.createHash(\"sha256\").update(fs.readFileSync(filePath)).digest(\"hex\");\n}\n\nfunction toProjectPath(filePath) {\n return path.relative(rootDir, filePath).replace(/\\\\/g, \"/\");\n}\n\nfunction assertSafeUiAssetPath(assetPath, fieldName, issues) {\n if (assetPath == null || assetPath === \"不适用\") return;\n if (typeof assetPath !== \"string\") {\n issues.push(errorIssue(fieldName, \"路径字段必须是字符串\"));\n return;\n }\n if (!assetPath.startsWith(\"/assets/ui/\")) {\n issues.push(errorIssue(fieldName, \"路径必须位于 /assets/ui/ 下\"));\n }\n if (assetPath.includes(\"..\")) issues.push(errorIssue(fieldName, \"路径不得包含父目录跳转\"));\n if (/^https?:\\/\\//i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用远程 URL\"));\n if (/^file:\\/\\//i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用 file URL\"));\n if (/^data:/i.test(assetPath)) issues.push(errorIssue(fieldName, \"路径不得使用 data URL\"));\n if (forbiddenSecretOrLocalPath.test(assetPath)) {\n issues.push(errorIssue(fieldName, \"路径包含本地路径、密钥或敏感存储形状\"));\n }\n}\n\nfunction resolveUiAssetPath(assetPath) {\n return path.join(rootDir, \"public\", assetPath.replace(/^\\//, \"\"));\n}\n\nfunction readUInt24LE(buffer, offset) {\n return buffer[offset] + (buffer[offset + 1] << 8) + (buffer[offset + 2] << 16);\n}\n\nfunction readWebpInfo(buffer) {\n if (buffer.toString(\"ascii\", 0, 4) !== \"RIFF\" || buffer.toString(\"ascii\", 8, 12) !== \"WEBP\") {\n throw new Error(\"Unsupported WebP container\");\n }\n\n let offset = 12;\n while (offset + 8 <= buffer.length) {\n const chunk = buffer.toString(\"ascii\", offset, offset + 4);\n const size = buffer.readUInt32LE(offset + 4);\n const data = offset + 8;\n if (chunk === \"VP8X\") {\n const flags = buffer.readUInt8(data);\n return {\n width: readUInt24LE(buffer, data + 4) + 1,\n height: readUInt24LE(buffer, data + 7) + 1,\n alpha: Boolean(flags & 0x10)\n };\n }\n if (chunk === \"VP8 \") {\n return {\n width: buffer.readUInt16LE(data + 6) & 0x3fff,\n height: buffer.readUInt16LE(data + 8) & 0x3fff,\n alpha: false\n };\n }\n if (chunk === \"VP8L\") {\n const b0 = buffer[data + 1];\n const b1 = buffer[data + 2];\n const b2 = buffer[data + 3];\n const b3 = buffer[data + 4];\n return {\n width: 1 + (((b1 & 0x3f) << 8) | b0),\n height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),\n alpha: true\n };\n }\n offset += 8 + size + (size % 2);\n }\n throw new Error(\"Unsupported WebP encoding\");\n}\n\nfunction readImageInfo(filePath) {\n if (!filePath.endsWith(\".webp\")) throw new Error(`Unsupported UI asset format: ${filePath}`);\n return readWebpInfo(fs.readFileSync(filePath));\n}\n\nfunction errorIssue(target, message) {\n return { severity: \"error\", target, message };\n}\n\nfunction warningIssue(target, message) {\n return { severity: \"warning\", target, message };\n}\n\nfunction collectForbiddenValueIssues(value, label, issues) {\n if (value == null) return;\n if (typeof value === \"string\") {\n if (forbiddenSecretOrLocalPath.test(value)) {\n issues.push(errorIssue(label, \"字段包含密钥、本地路径、远程 URL 或敏感存储形状\"));\n }\n if (forbiddenAssetValue.test(value)) {\n issues.push(errorIssue(label, \"字段包含 raw/hidden/prompt/provider/audit 禁用内容\"));\n }\n return;\n }\n if (Array.isArray(value)) {\n value.forEach((item, index) => collectForbiddenValueIssues(item, `${label}[${index}]`, issues));\n return;\n }\n if (typeof value === \"object\") {\n for (const [key, nestedValue] of Object.entries(value)) {\n collectForbiddenValueIssues(nestedValue, `${label}.${key}`, issues);\n }\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const explicitPath = options.browserPath || process.env.BROWSER_EXECUTABLE_PATH;\n if (explicitPath) {\n if (fs.existsSync(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n const candidate = getDefaultBrowserCandidates().find((candidatePath) => fs.existsSync(candidatePath));\n if (candidate) return candidate;\n throw new Error(\"No Chrome/Edge executable found. Set BROWSER_EXECUTABLE_PATH or pass --browser <path>.\");\n}\n\nasync function analyzeTransparentPixels(assets, options) {\n const transparentAssets = assets.filter((asset) => asset.transparent);\n if (transparentAssets.length === 0) {\n return { enabled: false, reason: \"no transparent assets\", assets: {} };\n }\n\n const { chromium } = require(\"playwright-core\");\n const browserPath = resolveBrowserExecutable(options);\n const browser = await chromium.launch({ executablePath: browserPath, headless: true });\n const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });\n const results = {};\n\n try {\n for (const asset of transparentAssets) {\n const absolutePath = resolveUiAssetPath(asset.path);\n const dataUrl = `data:image/webp;base64,${fs.readFileSync(absolutePath).toString(\"base64\")}`;\n results[asset.id] = await page.evaluate(\n async ({ dataUrl: source, thresholds, backgrounds }) => {\n function loadImage(src) {\n return new Promise((resolve, reject) => {\n const img = new Image();\n img.onload = () => resolve(img);\n img.onerror = () => reject(new Error(\"image decode failed\"));\n img.src = src;\n });\n }\n\n function parseHexColor(hex) {\n const value = hex.replace(\"#\", \"\");\n return {\n r: parseInt(value.slice(0, 2), 16),\n g: parseInt(value.slice(2, 4), 16),\n b: parseInt(value.slice(4, 6), 16)\n };\n }\n\n function luma(r, g, b) {\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n }\n\n const img = await loadImage(source);\n const canvas = document.createElement(\"canvas\");\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n ctx.drawImage(img, 0, 0);\n const image = ctx.getImageData(0, 0, canvas.width, canvas.height);\n const data = image.data;\n const totalPixels = canvas.width * canvas.height;\n let visibleAlphaPixels = 0;\n let borderVisibleAlphaPixels = 0;\n let highSaturationGreenOrMagentaPixels = 0;\n let hardAlphaJumpPixels = 0;\n const composite = {};\n\n for (const background of backgrounds) {\n composite[background.id] = {\n minLuma: 255,\n maxLuma: 0,\n averageLuma: 0\n };\n }\n\n for (let y = 0; y < canvas.height; y += 1) {\n for (let x = 0; x < canvas.width; x += 1) {\n const offset = (y * canvas.width + x) * 4;\n const r = data[offset];\n const g = data[offset + 1];\n const b = data[offset + 2];\n const a = data[offset + 3];\n const visible = a > 16;\n if (visible) {\n visibleAlphaPixels += 1;\n if (\n x < thresholds.borderSampleWidthPx ||\n y < thresholds.borderSampleWidthPx ||\n x >= canvas.width - thresholds.borderSampleWidthPx ||\n y >= canvas.height - thresholds.borderSampleWidthPx\n ) {\n borderVisibleAlphaPixels += 1;\n }\n const greenEdge = g > 140 && g > r * 1.35 && g > b * 1.15;\n const magentaEdge = r > 130 && b > 115 && Math.abs(r - b) < 90 && g < Math.min(r, b) * 0.65;\n if (greenEdge || magentaEdge) highSaturationGreenOrMagentaPixels += 1;\n }\n\n if (x + 1 < canvas.width) {\n const rightAlpha = data[offset + 7];\n if (Math.abs(a - rightAlpha) >= thresholds.hardAlphaJumpThreshold) hardAlphaJumpPixels += 1;\n }\n if (y + 1 < canvas.height) {\n const downAlpha = data[offset + canvas.width * 4 + 3];\n if (Math.abs(a - downAlpha) >= thresholds.hardAlphaJumpThreshold) hardAlphaJumpPixels += 1;\n }\n\n for (const background of backgrounds) {\n const bg = parseHexColor(background.color);\n const alpha = a / 255;\n const cr = r * alpha + bg.r * (1 - alpha);\n const cg = g * alpha + bg.g * (1 - alpha);\n const cb = b * alpha + bg.b * (1 - alpha);\n const lum = luma(cr, cg, cb);\n const summary = composite[background.id];\n summary.minLuma = Math.min(summary.minLuma, Math.round(lum));\n summary.maxLuma = Math.max(summary.maxLuma, Math.round(lum));\n summary.averageLuma += lum;\n }\n }\n }\n\n for (const background of backgrounds) {\n composite[background.id].averageLuma = Number((composite[background.id].averageLuma / totalPixels).toFixed(2));\n }\n\n return {\n width: canvas.width,\n height: canvas.height,\n visibleAlphaPixels,\n visibleAlphaRatio: Number((visibleAlphaPixels / totalPixels).toFixed(4)),\n borderVisibleAlphaPixels,\n highSaturationGreenOrMagentaPixels,\n hardAlphaJumpPixels,\n composite\n };\n },\n { dataUrl, thresholds: pixelThresholds, backgrounds: compositeBackgrounds }\n );\n }\n } finally {\n await browser.close();\n }\n\n return {\n enabled: true,\n browserExecutable: browserPath,\n assets: results\n };\n}\n\nfunction findSidecarMetrics(asset) {\n if (asset.phase === \"S73.4\") {\n const sidecarPath = path.join(rootDir, \"public\", \"assets\", \"ui\", \"home\", \"home-transparency-qa-v1.json\");\n if (!fs.existsSync(sidecarPath)) return null;\n const sidecar = readJson(sidecarPath);\n const entry = sidecar.assets.find((candidate) => candidate.id === asset.id);\n if (!entry) return null;\n return {\n source: \"home-transparency-qa-v1\",\n path: toProjectPath(sidecarPath),\n sha256: entry.sha256,\n metrics: entry.metrics\n };\n }\n\n if (asset.phase === \"S73.8\" && asset.performance && typeof asset.performance.visibleAlphaRatio === \"number\") {\n return {\n source: \"manifest-performance-alpha\",\n metrics: {\n visibleAlphaRatio: asset.performance.visibleAlphaRatio,\n borderVisibleAlphaPixels: asset.performance.borderVisibleAlphaPixels\n }\n };\n }\n\n return null;\n}\n\nfunction buildAssetRecord(asset, manifest, pixelAnalysis, issues) {\n const assetIssues = [];\n collectForbiddenValueIssues(asset, `assets.${asset.id}`, assetIssues);\n assertSafeUiAssetPath(asset.path, `${asset.id}.path`, assetIssues);\n assertSafeUiAssetPath(asset.thumbnailPath, `${asset.id}.thumbnailPath`, assetIssues);\n assertSafeUiAssetPath(asset.lowResPlaceholderPath, `${asset.id}.lowResPlaceholderPath`, assetIssues);\n\n const filePath = resolveUiAssetPath(asset.path);\n const thumbnailPath = resolveUiAssetPath(asset.thumbnailPath);\n const fallbackIds = new Set((manifest.fallbackCatalog || []).map((fallback) => fallback.id));\n const runtimeUsable = (manifest.runtimeUsableReviewStatuses || []).includes(asset.reviewStatus);\n\n const files = {\n image: {\n path: asset.path,\n exists: fs.existsSync(filePath)\n },\n thumbnail: {\n path: asset.thumbnailPath,\n exists: asset.thumbnailPath ? fs.existsSync(thumbnailPath) : false\n }\n };\n\n let imageInfo = null;\n if (files.image.exists) {\n files.image.bytes = fs.statSync(filePath).size;\n files.image.sha256 = sha256File(filePath);\n imageInfo = readImageInfo(filePath);\n files.image.dimensions = { width: imageInfo.width, height: imageInfo.height };\n files.image.alpha = imageInfo.alpha;\n } else {\n assetIssues.push(errorIssue(asset.id, \"素材文件不存在\"));\n }\n\n if (files.thumbnail.exists) {\n files.thumbnail.bytes = fs.statSync(thumbnailPath).size;\n files.thumbnail.sha256 = sha256File(thumbnailPath);\n files.thumbnail.dimensions = readImageInfo(thumbnailPath);\n } else {\n assetIssues.push(errorIssue(asset.id, \"缩略图文件不存在\"));\n }\n\n if (imageInfo) {\n if (imageInfo.width !== asset.dimensions.width || imageInfo.height !== asset.dimensions.height) {\n assetIssues.push(errorIssue(asset.id, \"manifest 尺寸与图片实际尺寸不一致\"));\n }\n if (imageInfo.alpha !== asset.transparent) {\n assetIssues.push(errorIssue(asset.id, \"manifest transparent 与图片 alpha 不一致\"));\n }\n }\n\n if (!runtimeUsable) assetIssues.push(errorIssue(asset.id, \"active assets 必须是 runtime 可用审核状态\"));\n if (asset.category !== \"fallback\" && !fallbackIds.has(asset.fallbackRef)) {\n assetIssues.push(errorIssue(asset.id, \"fallbackRef 未指向 fallbackCatalog\"));\n }\n if (files.image.bytes && asset.performance?.bytes !== files.image.bytes) {\n assetIssues.push(errorIssue(asset.id, \"performance.bytes 与文件大小不一致\"));\n }\n if (files.thumbnail.bytes && asset.performance?.thumbnailBytes !== files.thumbnail.bytes) {\n assetIssues.push(errorIssue(asset.id, \"performance.thumbnailBytes 与文件大小不一致\"));\n }\n if (files.image.bytes && asset.performance?.targetMaxBytes && files.image.bytes > asset.performance.targetMaxBytes) {\n assetIssues.push(errorIssue(asset.id, \"素材超过 targetMaxBytes\"));\n }\n if (\n files.thumbnail.bytes &&\n asset.performance?.thumbnailTargetMaxBytes &&\n files.thumbnail.bytes > asset.performance.thumbnailTargetMaxBytes\n ) {\n assetIssues.push(errorIssue(asset.id, \"缩略图超过 thumbnailTargetMaxBytes\"));\n }\n\n const transparentQa = {\n required: Boolean(asset.transparent),\n backgrounds: asset.transparent ? compositeBackgrounds : [],\n sidecar: asset.transparent ? findSidecarMetrics(asset) : null,\n pixel: asset.transparent ? pixelAnalysis.assets[asset.id] || null : null\n };\n\n if (asset.transparent && !transparentQa.pixel) {\n assetIssues.push(errorIssue(asset.id, \"透明素材缺少 S73.9 pixel QA 指标\"));\n }\n\n if (transparentQa.pixel) {\n const pixel = transparentQa.pixel;\n if (pixel.highSaturationGreenOrMagentaPixels > pixelThresholds.maxHighSaturationGreenOrMagentaPixels) {\n assetIssues.push(warningIssue(asset.id, \"透明素材存在较多高饱和绿/紫像素,需人工查看预览\"));\n }\n if (pixel.borderVisibleAlphaPixels > pixelThresholds.maxBorderVisibleAlphaPixels) {\n assetIssues.push(warningIssue(asset.id, \"透明素材边界可见 alpha 较多,需检查是否为矩形切口或合法边缘\"));\n }\n }\n\n if (asset.category === \"portrait\") {\n const placeholderPath = resolveUiAssetPath(asset.lowResPlaceholderPath);\n files.lowResPlaceholder = {\n path: asset.lowResPlaceholderPath,\n exists: fs.existsSync(placeholderPath)\n };\n if (!files.lowResPlaceholder.exists) {\n assetIssues.push(errorIssue(asset.id, \"立绘缺少低清占位\"));\n } else {\n files.lowResPlaceholder.bytes = fs.statSync(placeholderPath).size;\n files.lowResPlaceholder.sha256 = sha256File(placeholderPath);\n files.lowResPlaceholder.dimensions = readImageInfo(placeholderPath);\n if (asset.performance?.lowResPlaceholderBytes !== files.lowResPlaceholder.bytes) {\n assetIssues.push(errorIssue(asset.id, \"低清占位大小与 performance 不一致\"));\n }\n }\n if (asset.lazyLoad?.allowEagerLoad !== false) {\n assetIssues.push(errorIssue(asset.id, \"立绘不得允许 eager load\"));\n }\n if (!asset.portraitRef || asset.portraitRef !== asset.id) {\n assetIssues.push(errorIssue(asset.id, \"立绘必须以 id 作为 portraitRef\"));\n }\n }\n\n issues.push(...assetIssues);\n return {\n id: asset.id,\n phase: asset.phase,\n category: asset.category,\n subcategory: asset.subcategory,\n usage: asset.usage,\n path: asset.path,\n thumbnailPath: asset.thumbnailPath,\n fallbackRef: asset.fallbackRef,\n reviewStatus: asset.reviewStatus,\n runtimeUsable,\n dimensions: asset.dimensions,\n transparent: asset.transparent,\n files,\n transparentQa,\n issues: assetIssues\n };\n}\n\nasync function buildReport(options) {\n const manifestText = fs.readFileSync(manifestPath, \"utf8\");\n const manifest = JSON.parse(manifestText);\n const issues = [];\n collectForbiddenValueIssues(manifest.assets, \"manifest.assets\", issues);\n\n const assets = manifest.assets || [];\n const pixelAnalysis = options.pixel\n ? await analyzeTransparentPixels(assets, options)\n : { enabled: false, reason: \"run with --pixel to refresh transparent pixel metrics\", assets: {} };\n\n const assetReports = assets.map((asset) => buildAssetRecord(asset, manifest, pixelAnalysis, issues));\n const summary = assetReports.reduce(\n (acc, asset) => {\n acc.totalAssets += 1;\n acc.transparentAssets += asset.transparent ? 1 : 0;\n acc.byPhase[asset.phase] = (acc.byPhase[asset.phase] || 0) + 1;\n acc.byCategory[asset.category] = (acc.byCategory[asset.category] || 0) + 1;\n acc.byReviewStatus[asset.reviewStatus] = (acc.byReviewStatus[asset.reviewStatus] || 0) + 1;\n return acc;\n },\n { totalAssets: 0, transparentAssets: 0, byPhase: {}, byCategory: {}, byReviewStatus: {} }\n );\n\n summary.errorCount = issues.filter((issue) => issue.severity === \"error\").length;\n summary.warningCount = issues.filter((issue) => issue.severity === \"warning\").length;\n\n return {\n schemaVersion: 1,\n phase: \"S73.9\",\n generatedAt: new Date().toISOString(),\n manifestRef: toProjectPath(manifestPath),\n manifestSha256: sha256Text(manifestText),\n previewRef: toProjectPath(previewPath),\n compositeBackgrounds,\n thresholds: pixelThresholds,\n pixelAnalysis: {\n enabled: pixelAnalysis.enabled,\n browserExecutable: pixelAnalysis.browserExecutable ? path.basename(pixelAnalysis.browserExecutable) : null,\n reason: pixelAnalysis.reason || null\n },\n summary,\n assets: assetReports,\n issues\n };\n}\n\nfunction validateCommittedReport(reportPath) {\n if (!fs.existsSync(reportPath)) {\n throw new Error(`Missing QA report: ${toProjectPath(reportPath)}`);\n }\n const manifestText = fs.readFileSync(manifestPath, \"utf8\");\n const manifest = JSON.parse(manifestText);\n const report = readJson(reportPath);\n\n const failures = [];\n if (report.schemaVersion !== 1) failures.push(\"schemaVersion must be 1\");\n if (report.phase !== \"S73.9\") failures.push(\"phase must be S73.9\");\n if (report.manifestSha256 !== sha256Text(manifestText)) failures.push(\"manifestSha256 is stale\");\n if (!fs.existsSync(previewPath)) failures.push(\"asset QA preview page is missing\");\n\n const manifestAssets = manifest.assets || [];\n const assetsById = new Map((report.assets || []).map((asset) => [asset.id, asset]));\n if ((report.assets || []).length !== manifestAssets.length) {\n failures.push(\"report.assets length does not match manifest assets length\");\n }\n for (const entry of report.assets || []) {\n if (!manifestAssets.some((asset) => asset.id === entry.id)) {\n failures.push(`QA report has stale asset ${entry.id}`);\n }\n }\n\n for (const asset of manifestAssets) {\n const entry = assetsById.get(asset.id);\n if (!entry) {\n failures.push(`QA report missing asset ${asset.id}`);\n continue;\n }\n if (entry.reviewStatus !== asset.reviewStatus) failures.push(`${asset.id} reviewStatus is stale`);\n if (entry.path !== asset.path) failures.push(`${asset.id} path is stale`);\n if (entry.thumbnailPath !== asset.thumbnailPath) failures.push(`${asset.id} thumbnailPath is stale`);\n if (asset.transparent && !entry.transparentQa?.pixel) {\n failures.push(`${asset.id} missing transparent pixel QA`);\n }\n if (entry.files?.image?.exists !== true) failures.push(`${asset.id} image file missing in QA report`);\n if (entry.files?.image?.sha256 && entry.files.image.sha256 !== sha256File(resolveUiAssetPath(asset.path))) {\n failures.push(`${asset.id} image sha256 is stale`);\n }\n if (entry.files?.image?.bytes !== fs.statSync(resolveUiAssetPath(asset.path)).size) {\n failures.push(`${asset.id} image bytes are stale`);\n }\n if (asset.thumbnailPath) {\n const thumbnailPath = resolveUiAssetPath(asset.thumbnailPath);\n if (entry.files?.thumbnail?.exists !== true) failures.push(`${asset.id} thumbnail file missing in QA report`);\n if (entry.files?.thumbnail?.sha256 !== sha256File(thumbnailPath)) {\n failures.push(`${asset.id} thumbnail sha256 is stale`);\n }\n if (entry.files?.thumbnail?.bytes !== fs.statSync(thumbnailPath).size) {\n failures.push(`${asset.id} thumbnail bytes are stale`);\n }\n }\n if (asset.category === \"portrait\" && asset.lowResPlaceholderPath) {\n const placeholderPath = resolveUiAssetPath(asset.lowResPlaceholderPath);\n if (entry.files?.lowResPlaceholder?.exists !== true) {\n failures.push(`${asset.id} low-res placeholder file missing in QA report`);\n }\n if (entry.files?.lowResPlaceholder?.sha256 !== sha256File(placeholderPath)) {\n failures.push(`${asset.id} low-res placeholder sha256 is stale`);\n }\n if (entry.files?.lowResPlaceholder?.bytes !== fs.statSync(placeholderPath).size) {\n failures.push(`${asset.id} low-res placeholder bytes are stale`);\n }\n }\n }\n\n if (report.summary?.totalAssets !== manifestAssets.length) failures.push(\"summary.totalAssets is stale\");\n const reportErrors = (report.issues || []).filter((issue) => issue.severity === \"error\");\n if (reportErrors.length > 0) {\n failures.push(`QA report contains ${reportErrors.length} blocking error(s)`);\n }\n\n if (failures.length > 0) {\n throw new Error(`Frontend asset QA failed:\\n- ${failures.join(\"\\n- \")}`);\n }\n\n return report;\n}\n\nasync function main() {\n const options = parseArgs(process.argv.slice(2));\n if (options.write) {\n const report = await buildReport(options);\n fs.writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\\n`);\n console.log(\n `Wrote ${toProjectPath(options.reportPath)} (${report.summary.totalAssets} assets, ${report.summary.transparentAssets} transparent, ${report.summary.errorCount} errors, ${report.summary.warningCount} warnings)`\n );\n if (report.summary.errorCount > 0) process.exitCode = 1;\n }\n\n if (options.check) {\n const report = validateCommittedReport(options.reportPath);\n console.log(\n `S73.9 frontend asset QA ok: ${report.summary.totalAssets} assets, ${report.summary.transparentAssets} transparent assets, ${report.summary.warningCount} visual warning(s).`\n );\n }\n}\n\nif (require.main === module) {\n main().catch((error) => {\n console.error(error.stack || error.message);\n process.exit(1);\n });\n}\n\nmodule.exports = {\n buildReport,\n validateCommittedReport,\n readWebpInfo\n};\n"}}, "scripts/browserSmoke.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595220.4771633, "n": 175062, "h": "3e3f240b6c57bd91", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst gameViewVisibleTimeoutMs = 30000;\nconst turnIdleTimeoutMs = 120000;\nconst examFlowTimeoutMs = 45000;\nconst mapCanvasSampleGridSize = 12;\nconst mapCanvasMinimumChangedSamples = 6;\nconst mapMaxAnimatedEffects = 40;\nconst DEFAULT_SMOKE_STORAGE_ADAPTER = \"sqlite\";\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n smokeInput: \"waterworks along the canal\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n smokeInput: \"campaign at the border\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n smokeInput: \"appoint clean personnel\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n smokeInput: \"impeach corrupt official\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createSqliteSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || DEFAULT_SMOKE_STORAGE_ADAPTER).trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nfunction legacyStartUrl(baseUrl) {\n const url = new URL(baseUrl);\n if (!url.pathname || url.pathname === \"/\") {\n url.pathname = \"/index.html\";\n }\n return url.toString();\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n const startUrl = legacyStartUrl(baseUrl);\n await page.goto(startUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(startUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n QIANQIU_FORCE_LEGACY_CLIENT: \"1\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelScrollWidth > metrics.mapPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map panel has horizontal scroll overflow (${roundMetric(metrics.mapPanelScrollWidth)}px > ${roundMetric(metrics.mapPanelClientWidth)}px).`\n );\n }\n\n if (metrics.mapUiLayerClientWidth > 0 && metrics.mapUiLayerScrollWidth > metrics.mapUiLayerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map UI layer has horizontal scroll overflow (${roundMetric(metrics.mapUiLayerScrollWidth)}px > ${roundMetric(metrics.mapUiLayerClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveClientWidth > 0 && metrics.imperialExamArchiveScrollWidth > metrics.imperialExamArchiveClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive panel has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveGridClientWidth > 0 && metrics.imperialExamArchiveGridScrollWidth > metrics.imperialExamArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive grid has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveGridScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelHeight < 180) {\n failures.push(`${mode} map panel is too short (${roundMetric(metrics.mapPanelHeight)}px).`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const mapPanel = box(\"#map-panel\");\n const mapUiLayer = box(\"#map-ui-layer\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const imperialExamArchive = box(\"#imperial-exam-archive-panel\");\n const imperialExamArchiveGrid = box(\"#imperial-exam-archive-panel .imperial-exam-archive-grid\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n mapPanelClientWidth: mapPanel?.clientWidth || 0,\n mapPanelHeight: mapPanel?.height || 0,\n mapPanelScrollWidth: mapPanel?.scrollWidth || 0,\n mapPanelWidth: mapPanel?.width || 0,\n mapUiLayerClientWidth: mapUiLayer?.clientWidth || 0,\n mapUiLayerScrollWidth: mapUiLayer?.scrollWidth || 0,\n mapUiLayerWidth: mapUiLayer?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n imperialExamArchiveClientWidth: imperialExamArchive?.clientWidth || 0,\n imperialExamArchiveScrollWidth: imperialExamArchive?.scrollWidth || 0,\n imperialExamArchiveWidth: imperialExamArchive?.width || 0,\n imperialExamArchiveGridClientWidth: imperialExamArchiveGrid?.clientWidth || 0,\n imperialExamArchiveGridScrollWidth: imperialExamArchiveGrid?.scrollWidth || 0,\n imperialExamArchiveGridWidth: imperialExamArchiveGrid?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst imperialExamArchiveSections = [\"study\", \"procedure\", \"ranking\", \"network\", \"appointment\"];\n\nfunction getImperialExamArchivePanelFailures(snapshot = {}, expectations = {}, mode = \"imperial exam archive\") {\n const failures = [];\n const expectedSections = expectations.expectedSections || imperialExamArchiveSections;\n const availableSections = new Set(snapshot.sections || []);\n const missingSections = expectedSections.filter((section) => !availableSections.has(section));\n if (missingSections.length) {\n failures.push(`${mode} panel is missing sections: ${missingSections.join(\", \")}.`);\n }\n\n const sourceViews = new Set(snapshot.sourceViews || []);\n const expectedSourceViews = expectations.expectedSourceViews || [\n \"studyProfileView\",\n \"examProcedureView+examinerPanelView\",\n \"examHonorView+examHistory\",\n \"relationshipView+worldPeopleView\",\n \"appointmentTrackView\"\n ];\n const missingSourceViews = expectedSourceViews.filter((sourceView) => !sourceViews.has(sourceView));\n if (missingSourceViews.length) {\n failures.push(`${mode} panel is missing source views: ${missingSourceViews.join(\", \")}.`);\n }\n\n if (Number(snapshot.cardCount || 0) < expectedSections.length) {\n failures.push(`${mode} panel rendered ${snapshot.cardCount || 0} archive cards for ${expectedSections.length} expected sections.`);\n }\n if (Number(snapshot.metricCount || 0) < (expectations.minMetricCount || expectedSections.length * 2)) {\n failures.push(`${mode} panel did not render enough visible metrics.`);\n }\n if (expectations.expectAppointment !== false && snapshot.appointmentTrackView !== \"ready\") {\n failures.push(`${mode} panel did not mark appointmentTrackView ready.`);\n }\n if (expectations.expectHistory && Number(snapshot.historyCount || 0) < expectations.expectHistory) {\n failures.push(`${mode} panel expected at least ${expectations.expectHistory} exam history rows, got ${snapshot.historyCount || 0}.`);\n }\n if (expectations.minRankingRows && Number(snapshot.rankingRows || 0) < expectations.minRankingRows) {\n failures.push(`${mode} panel rendered too few ranking rows.`);\n }\n if (expectations.minNetworkContacts && Number(snapshot.networkContactCount || 0) < expectations.minNetworkContacts) {\n failures.push(`${mode} panel rendered too few network contact rows.`);\n }\n\n const defaultHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"examiner_reviews\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"raw audit\",\n \"raw proposal\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertImperialExamArchivePanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#imperial-exam-archive-panel\", `${mode} imperial exam archive panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#imperial-exam-archive-panel\");\n const cards = [...document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-archive-card\")];\n return {\n studyProfileView: panel?.dataset.studyProfileView || \"\",\n examProcedureView: panel?.dataset.examProcedureView || \"\",\n examinerPanelView: panel?.dataset.examinerPanelView || \"\",\n examHonorView: panel?.dataset.examHonorView || \"\",\n relationshipView: panel?.dataset.relationshipView || \"\",\n worldPeopleView: panel?.dataset.worldPeopleView || \"\",\n appointmentTrackView: panel?.dataset.appointmentTrackView || \"\",\n historyCount: panel?.dataset.historyCount || \"0\",\n cardCount: cards.length,\n sections: cards.map((card) => card.dataset.section).filter(Boolean),\n sourceViews: cards.map((card) => card.dataset.sourceView).filter(Boolean),\n metricCount: document.querySelectorAll(\"#imperial-exam-archive-panel .information-card-metric\").length,\n rankingRows: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-ranking li\").length,\n networkContactCount: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-network p\").length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getImperialExamArchivePanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} imperial exam archive`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n if (expectedIds.length) {\n await page\n .waitForFunction(\n ({ scope: saveScope, expectedIds: ids }) => {\n const actualIds = Array.from(document.querySelectorAll(`${saveScope} .save-card`))\n .map((card) => card.dataset.saveId)\n .filter(Boolean);\n return ids.every((id) => actualIds.includes(id));\n },\n { scope, expectedIds },\n { timeout: 15000 }\n )\n .catch(() => null);\n }\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nfunction getMapPanelFailures(snapshot = {}, expectations = {}, mode = \"map\") {\n const failures = [];\n const hiddenTextTokens = expectations.hiddenTextTokens || [];\n const panelText = String(snapshot.text || \"\");\n\n if (!snapshot.visible) {\n failures.push(`${mode} map panel is not visible.`);\n }\n if (!snapshot.canvasCount) {\n failures.push(`${mode} map panel did not mount a PixiJS canvas.`);\n }\n if (snapshot.canvasWidth <= 0 || snapshot.canvasHeight <= 0) {\n failures.push(`${mode} map canvas has no drawable size.`);\n }\n if (!snapshot.canvasNonBlank) {\n failures.push(`${mode} map canvas appears blank.`);\n }\n if (snapshot.labelCount < (expectations.minLabels || 1)) {\n failures.push(`${mode} map panel rendered too few labels (${snapshot.labelCount}).`);\n }\n if (snapshot.tooltipOutOfBounds) {\n failures.push(`${mode} map tooltip overflows the map panel.`);\n }\n if (snapshot.debug?.renderer?.animatedEffectCount > mapMaxAnimatedEffects) {\n failures.push(`${mode} map animated effect count exceeds cap (${snapshot.debug.renderer.animatedEffectCount}).`);\n }\n if (expectations.expectFallbackAssetMode && snapshot.debug?.renderer?.assetMode !== \"fallback\") {\n failures.push(`${mode} map did not enter fallback asset mode.`);\n }\n if (expectations.expectReducedMotion && !snapshot.debug?.renderer?.reducedMotion) {\n failures.push(`${mode} map renderer did not observe prefers-reduced-motion.`);\n }\n if (expectations.expectReducedMotion && snapshot.debug?.renderer?.animatedEffectCount !== 0) {\n failures.push(`${mode} map renderer created animations while reduced motion was requested.`);\n }\n if (expectations.expectPixiFallbackText && !/舆图组件暂缺|舆图资料待生成/.test(panelText)) {\n failures.push(`${mode} map did not render the static PixiJS fallback text.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && panelText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${mode} map panel leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function readMapPanelSnapshot(page) {\n return page.evaluate(({ sampleGridSize, minimumChangedSamples }) => {\n const panel = document.querySelector(\"#map-panel\");\n const canvas = document.querySelector(\"#map-canvas canvas\");\n const panelRect = panel?.getBoundingClientRect();\n let canvasNonBlank = false;\n let changedSamples = 0;\n let canvasWidth = 0;\n let canvasHeight = 0;\n\n if (canvas) {\n canvasWidth = canvas.width;\n canvasHeight = canvas.height;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n canvasNonBlank = changedSamples >= minimumChangedSamples;\n } catch (error) {\n canvasNonBlank = false;\n }\n }\n\n const tooltip = document.querySelector(\"#map-ui-layer .map-tooltip\");\n const tooltipRect = tooltip?.getBoundingClientRect();\n const tooltipOutOfBounds = Boolean(\n panelRect &&\n tooltipRect &&\n (\n tooltipRect.left < panelRect.left - 2 ||\n tooltipRect.right > panelRect.right + 2 ||\n tooltipRect.top < panelRect.top - 2 ||\n tooltipRect.bottom > panelRect.bottom + 2\n )\n );\n\n return {\n canvasCount: document.querySelectorAll(\"#map-canvas canvas\").length,\n canvasHeight,\n canvasNonBlank,\n canvasWidth,\n changedSamples,\n debug: window.QianqiuMapRenderer?.getDebugState?.() || null,\n labelCount: document.querySelectorAll(\"#map-ui-layer .map-label\").length,\n text: panel?.innerText || \"\",\n tooltipOutOfBounds,\n visible: Boolean(panel && !panel.hidden && getComputedStyle(panel).display !== \"none\")\n };\n }, {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n });\n}\n\nasync function assertMapPanel(page, mode, expectations = {}) {\n await page.locator(\"#map-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n const canvas = document.querySelector(\"#map-canvas canvas\");\n return debug && (debug.renderer || debug.fallbackText) && (canvas || debug.fallbackText);\n }, null, { timeout: 10000 });\n\n if (!expectations.expectPixiFallbackText) {\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n return debug?.renderer?.assetMode === \"loaded\" || debug?.renderer?.assetMode === \"fallback\";\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n ({ sampleGridSize, minimumChangedSamples }) => {\n const canvas = document.querySelector(\"#map-canvas canvas\");\n if (!canvas || canvas.width <= 0 || canvas.height <= 0) return false;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return false;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n let changedSamples = 0;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n return changedSamples >= minimumChangedSamples;\n } catch {\n return false;\n }\n },\n {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n },\n { timeout: 30000 }\n );\n }\n\n const snapshot = await readMapPanelSnapshot(page);\n const failures = getMapPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode)\n .filter((failure) => /map/.test(failure));\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function startScholarGame(page, options = {}) {\n await openCleanStartPage(page, options.baseUrl);\n await assertStartRoleOptions(page);\n await page.locator('input[name=\"dynasty\"]').fill(options.dynasty || \"Ming\");\n await page.locator('input[name=\"year\"]').fill(options.year || \"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(options.playerName || \"Browser Map Smoke\");\n await page.locator('input[name=\"background\"]').fill(options.background || \"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(options.customSetting || \"map browser acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n return sessionId;\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertImperialExamArchivePanel(page, \"complete exam progression official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertMapPanel(page, \"mobile map\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"mobile scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMapResourceFailureAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n try {\n await context.route(\"**/assets/maps/ink-map-manifest.json\", (route) => route.fulfill({\n status: 404,\n contentType: \"application/json\",\n body: \"{\\\"error\\\":\\\"missing manifest for fallback smoke\\\"}\"\n }));\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Fallback\",\n customSetting: \"map resource failure acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map resource fallback\");\n const snapshot = await assertMapPanel(page, \"map resource fallback\", {\n expectFallbackAssetMode: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n assetMode: snapshot.debug?.renderer?.assetMode || \"unknown\",\n markerCount: snapshot.debug?.renderer?.markerCount || 0\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runMapReducedMotionAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({\n viewport: VIEWPORTS.desktop,\n reducedMotion: \"reduce\"\n });\n let sessionId = null;\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Reduced Motion\",\n customSetting: \"map reduced motion acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map reduced motion\");\n const snapshot = await assertMapPanel(page, \"map reduced motion\", {\n expectReducedMotion: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n animatedEffectCount: snapshot.debug?.renderer?.animatedEffectCount || 0,\n reducedMotion: Boolean(snapshot.debug?.renderer?.reducedMotion)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await assertImperialExamArchivePanel(page, \"mobile post-palace official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n let context = null;\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.smokeInput || acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await context.close();\n context = null;\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n if (context) await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertMapPanel(page, \"desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar\", {\n expectAppointment: false,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after turn\", {\n expectAppointment: false,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after exam\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertMapPanel(page, \"desktop restored map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop restored scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(legacyStartUrl(baseUrl), { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertMapPanel(freshPage, \"fresh page desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(freshPage, \"fresh page desktop scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const mapResourceFallback = await runMapResourceFailureAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n const mapReducedMotion = await runMapReducedMotionAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\",\n \"pixi-map\",\n \"map-resource-fallback\",\n \"map-reduced-motion\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n mapReducedMotion,\n mapResourceFallback,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n if (server) {\n await server.stop();\n server = null;\n }\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n if (server) {\n await server.stop();\n server = null;\n }\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to sqlite.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getImperialExamArchivePanelFailures,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getMapPanelFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "d3cda35673477f11d4f8a79d98326acbebc27d09": {"t": 1785753933.814865, "n": 175062, "h": "3e3f240b6c57bd91", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst gameViewVisibleTimeoutMs = 30000;\nconst turnIdleTimeoutMs = 120000;\nconst examFlowTimeoutMs = 45000;\nconst mapCanvasSampleGridSize = 12;\nconst mapCanvasMinimumChangedSamples = 6;\nconst mapMaxAnimatedEffects = 40;\nconst DEFAULT_SMOKE_STORAGE_ADAPTER = \"sqlite\";\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n smokeInput: \"waterworks along the canal\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n smokeInput: \"campaign at the border\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n smokeInput: \"appoint clean personnel\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n smokeInput: \"impeach corrupt official\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createSqliteSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || DEFAULT_SMOKE_STORAGE_ADAPTER).trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nfunction legacyStartUrl(baseUrl) {\n const url = new URL(baseUrl);\n if (!url.pathname || url.pathname === \"/\") {\n url.pathname = \"/index.html\";\n }\n return url.toString();\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n const startUrl = legacyStartUrl(baseUrl);\n await page.goto(startUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(startUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n QIANQIU_FORCE_LEGACY_CLIENT: \"1\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelScrollWidth > metrics.mapPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map panel has horizontal scroll overflow (${roundMetric(metrics.mapPanelScrollWidth)}px > ${roundMetric(metrics.mapPanelClientWidth)}px).`\n );\n }\n\n if (metrics.mapUiLayerClientWidth > 0 && metrics.mapUiLayerScrollWidth > metrics.mapUiLayerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map UI layer has horizontal scroll overflow (${roundMetric(metrics.mapUiLayerScrollWidth)}px > ${roundMetric(metrics.mapUiLayerClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveClientWidth > 0 && metrics.imperialExamArchiveScrollWidth > metrics.imperialExamArchiveClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive panel has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveGridClientWidth > 0 && metrics.imperialExamArchiveGridScrollWidth > metrics.imperialExamArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive grid has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveGridScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelHeight < 180) {\n failures.push(`${mode} map panel is too short (${roundMetric(metrics.mapPanelHeight)}px).`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const mapPanel = box(\"#map-panel\");\n const mapUiLayer = box(\"#map-ui-layer\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const imperialExamArchive = box(\"#imperial-exam-archive-panel\");\n const imperialExamArchiveGrid = box(\"#imperial-exam-archive-panel .imperial-exam-archive-grid\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n mapPanelClientWidth: mapPanel?.clientWidth || 0,\n mapPanelHeight: mapPanel?.height || 0,\n mapPanelScrollWidth: mapPanel?.scrollWidth || 0,\n mapPanelWidth: mapPanel?.width || 0,\n mapUiLayerClientWidth: mapUiLayer?.clientWidth || 0,\n mapUiLayerScrollWidth: mapUiLayer?.scrollWidth || 0,\n mapUiLayerWidth: mapUiLayer?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n imperialExamArchiveClientWidth: imperialExamArchive?.clientWidth || 0,\n imperialExamArchiveScrollWidth: imperialExamArchive?.scrollWidth || 0,\n imperialExamArchiveWidth: imperialExamArchive?.width || 0,\n imperialExamArchiveGridClientWidth: imperialExamArchiveGrid?.clientWidth || 0,\n imperialExamArchiveGridScrollWidth: imperialExamArchiveGrid?.scrollWidth || 0,\n imperialExamArchiveGridWidth: imperialExamArchiveGrid?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst imperialExamArchiveSections = [\"study\", \"procedure\", \"ranking\", \"network\", \"appointment\"];\n\nfunction getImperialExamArchivePanelFailures(snapshot = {}, expectations = {}, mode = \"imperial exam archive\") {\n const failures = [];\n const expectedSections = expectations.expectedSections || imperialExamArchiveSections;\n const availableSections = new Set(snapshot.sections || []);\n const missingSections = expectedSections.filter((section) => !availableSections.has(section));\n if (missingSections.length) {\n failures.push(`${mode} panel is missing sections: ${missingSections.join(\", \")}.`);\n }\n\n const sourceViews = new Set(snapshot.sourceViews || []);\n const expectedSourceViews = expectations.expectedSourceViews || [\n \"studyProfileView\",\n \"examProcedureView+examinerPanelView\",\n \"examHonorView+examHistory\",\n \"relationshipView+worldPeopleView\",\n \"appointmentTrackView\"\n ];\n const missingSourceViews = expectedSourceViews.filter((sourceView) => !sourceViews.has(sourceView));\n if (missingSourceViews.length) {\n failures.push(`${mode} panel is missing source views: ${missingSourceViews.join(\", \")}.`);\n }\n\n if (Number(snapshot.cardCount || 0) < expectedSections.length) {\n failures.push(`${mode} panel rendered ${snapshot.cardCount || 0} archive cards for ${expectedSections.length} expected sections.`);\n }\n if (Number(snapshot.metricCount || 0) < (expectations.minMetricCount || expectedSections.length * 2)) {\n failures.push(`${mode} panel did not render enough visible metrics.`);\n }\n if (expectations.expectAppointment !== false && snapshot.appointmentTrackView !== \"ready\") {\n failures.push(`${mode} panel did not mark appointmentTrackView ready.`);\n }\n if (expectations.expectHistory && Number(snapshot.historyCount || 0) < expectations.expectHistory) {\n failures.push(`${mode} panel expected at least ${expectations.expectHistory} exam history rows, got ${snapshot.historyCount || 0}.`);\n }\n if (expectations.minRankingRows && Number(snapshot.rankingRows || 0) < expectations.minRankingRows) {\n failures.push(`${mode} panel rendered too few ranking rows.`);\n }\n if (expectations.minNetworkContacts && Number(snapshot.networkContactCount || 0) < expectations.minNetworkContacts) {\n failures.push(`${mode} panel rendered too few network contact rows.`);\n }\n\n const defaultHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"examiner_reviews\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"raw audit\",\n \"raw proposal\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertImperialExamArchivePanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#imperial-exam-archive-panel\", `${mode} imperial exam archive panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#imperial-exam-archive-panel\");\n const cards = [...document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-archive-card\")];\n return {\n studyProfileView: panel?.dataset.studyProfileView || \"\",\n examProcedureView: panel?.dataset.examProcedureView || \"\",\n examinerPanelView: panel?.dataset.examinerPanelView || \"\",\n examHonorView: panel?.dataset.examHonorView || \"\",\n relationshipView: panel?.dataset.relationshipView || \"\",\n worldPeopleView: panel?.dataset.worldPeopleView || \"\",\n appointmentTrackView: panel?.dataset.appointmentTrackView || \"\",\n historyCount: panel?.dataset.historyCount || \"0\",\n cardCount: cards.length,\n sections: cards.map((card) => card.dataset.section).filter(Boolean),\n sourceViews: cards.map((card) => card.dataset.sourceView).filter(Boolean),\n metricCount: document.querySelectorAll(\"#imperial-exam-archive-panel .information-card-metric\").length,\n rankingRows: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-ranking li\").length,\n networkContactCount: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-network p\").length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getImperialExamArchivePanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} imperial exam archive`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n if (expectedIds.length) {\n await page\n .waitForFunction(\n ({ scope: saveScope, expectedIds: ids }) => {\n const actualIds = Array.from(document.querySelectorAll(`${saveScope} .save-card`))\n .map((card) => card.dataset.saveId)\n .filter(Boolean);\n return ids.every((id) => actualIds.includes(id));\n },\n { scope, expectedIds },\n { timeout: 15000 }\n )\n .catch(() => null);\n }\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nfunction getMapPanelFailures(snapshot = {}, expectations = {}, mode = \"map\") {\n const failures = [];\n const hiddenTextTokens = expectations.hiddenTextTokens || [];\n const panelText = String(snapshot.text || \"\");\n\n if (!snapshot.visible) {\n failures.push(`${mode} map panel is not visible.`);\n }\n if (!snapshot.canvasCount) {\n failures.push(`${mode} map panel did not mount a PixiJS canvas.`);\n }\n if (snapshot.canvasWidth <= 0 || snapshot.canvasHeight <= 0) {\n failures.push(`${mode} map canvas has no drawable size.`);\n }\n if (!snapshot.canvasNonBlank) {\n failures.push(`${mode} map canvas appears blank.`);\n }\n if (snapshot.labelCount < (expectations.minLabels || 1)) {\n failures.push(`${mode} map panel rendered too few labels (${snapshot.labelCount}).`);\n }\n if (snapshot.tooltipOutOfBounds) {\n failures.push(`${mode} map tooltip overflows the map panel.`);\n }\n if (snapshot.debug?.renderer?.animatedEffectCount > mapMaxAnimatedEffects) {\n failures.push(`${mode} map animated effect count exceeds cap (${snapshot.debug.renderer.animatedEffectCount}).`);\n }\n if (expectations.expectFallbackAssetMode && snapshot.debug?.renderer?.assetMode !== \"fallback\") {\n failures.push(`${mode} map did not enter fallback asset mode.`);\n }\n if (expectations.expectReducedMotion && !snapshot.debug?.renderer?.reducedMotion) {\n failures.push(`${mode} map renderer did not observe prefers-reduced-motion.`);\n }\n if (expectations.expectReducedMotion && snapshot.debug?.renderer?.animatedEffectCount !== 0) {\n failures.push(`${mode} map renderer created animations while reduced motion was requested.`);\n }\n if (expectations.expectPixiFallbackText && !/舆图组件暂缺|舆图资料待生成/.test(panelText)) {\n failures.push(`${mode} map did not render the static PixiJS fallback text.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && panelText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${mode} map panel leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function readMapPanelSnapshot(page) {\n return page.evaluate(({ sampleGridSize, minimumChangedSamples }) => {\n const panel = document.querySelector(\"#map-panel\");\n const canvas = document.querySelector(\"#map-canvas canvas\");\n const panelRect = panel?.getBoundingClientRect();\n let canvasNonBlank = false;\n let changedSamples = 0;\n let canvasWidth = 0;\n let canvasHeight = 0;\n\n if (canvas) {\n canvasWidth = canvas.width;\n canvasHeight = canvas.height;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n canvasNonBlank = changedSamples >= minimumChangedSamples;\n } catch (error) {\n canvasNonBlank = false;\n }\n }\n\n const tooltip = document.querySelector(\"#map-ui-layer .map-tooltip\");\n const tooltipRect = tooltip?.getBoundingClientRect();\n const tooltipOutOfBounds = Boolean(\n panelRect &&\n tooltipRect &&\n (\n tooltipRect.left < panelRect.left - 2 ||\n tooltipRect.right > panelRect.right + 2 ||\n tooltipRect.top < panelRect.top - 2 ||\n tooltipRect.bottom > panelRect.bottom + 2\n )\n );\n\n return {\n canvasCount: document.querySelectorAll(\"#map-canvas canvas\").length,\n canvasHeight,\n canvasNonBlank,\n canvasWidth,\n changedSamples,\n debug: window.QianqiuMapRenderer?.getDebugState?.() || null,\n labelCount: document.querySelectorAll(\"#map-ui-layer .map-label\").length,\n text: panel?.innerText || \"\",\n tooltipOutOfBounds,\n visible: Boolean(panel && !panel.hidden && getComputedStyle(panel).display !== \"none\")\n };\n }, {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n });\n}\n\nasync function assertMapPanel(page, mode, expectations = {}) {\n await page.locator(\"#map-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n const canvas = document.querySelector(\"#map-canvas canvas\");\n return debug && (debug.renderer || debug.fallbackText) && (canvas || debug.fallbackText);\n }, null, { timeout: 10000 });\n\n if (!expectations.expectPixiFallbackText) {\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n return debug?.renderer?.assetMode === \"loaded\" || debug?.renderer?.assetMode === \"fallback\";\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n ({ sampleGridSize, minimumChangedSamples }) => {\n const canvas = document.querySelector(\"#map-canvas canvas\");\n if (!canvas || canvas.width <= 0 || canvas.height <= 0) return false;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return false;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n let changedSamples = 0;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n return changedSamples >= minimumChangedSamples;\n } catch {\n return false;\n }\n },\n {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n },\n { timeout: 30000 }\n );\n }\n\n const snapshot = await readMapPanelSnapshot(page);\n const failures = getMapPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode)\n .filter((failure) => /map/.test(failure));\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function startScholarGame(page, options = {}) {\n await openCleanStartPage(page, options.baseUrl);\n await assertStartRoleOptions(page);\n await page.locator('input[name=\"dynasty\"]').fill(options.dynasty || \"Ming\");\n await page.locator('input[name=\"year\"]').fill(options.year || \"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(options.playerName || \"Browser Map Smoke\");\n await page.locator('input[name=\"background\"]').fill(options.background || \"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(options.customSetting || \"map browser acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n return sessionId;\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertImperialExamArchivePanel(page, \"complete exam progression official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertMapPanel(page, \"mobile map\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"mobile scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMapResourceFailureAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n try {\n await context.route(\"**/assets/maps/ink-map-manifest.json\", (route) => route.fulfill({\n status: 404,\n contentType: \"application/json\",\n body: \"{\\\"error\\\":\\\"missing manifest for fallback smoke\\\"}\"\n }));\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Fallback\",\n customSetting: \"map resource failure acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map resource fallback\");\n const snapshot = await assertMapPanel(page, \"map resource fallback\", {\n expectFallbackAssetMode: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n assetMode: snapshot.debug?.renderer?.assetMode || \"unknown\",\n markerCount: snapshot.debug?.renderer?.markerCount || 0\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runMapReducedMotionAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({\n viewport: VIEWPORTS.desktop,\n reducedMotion: \"reduce\"\n });\n let sessionId = null;\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Reduced Motion\",\n customSetting: \"map reduced motion acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map reduced motion\");\n const snapshot = await assertMapPanel(page, \"map reduced motion\", {\n expectReducedMotion: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n animatedEffectCount: snapshot.debug?.renderer?.animatedEffectCount || 0,\n reducedMotion: Boolean(snapshot.debug?.renderer?.reducedMotion)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await assertImperialExamArchivePanel(page, \"mobile post-palace official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: examFlowTimeoutMs });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: turnIdleTimeoutMs });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n let context = null;\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.smokeInput || acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await context.close();\n context = null;\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n if (context) await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertMapPanel(page, \"desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar\", {\n expectAppointment: false,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after turn\", {\n expectAppointment: false,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after exam\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertMapPanel(page, \"desktop restored map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop restored scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(legacyStartUrl(baseUrl), { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: gameViewVisibleTimeoutMs });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertMapPanel(freshPage, \"fresh page desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(freshPage, \"fresh page desktop scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const mapResourceFallback = await runMapResourceFailureAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n const mapReducedMotion = await runMapReducedMotionAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\",\n \"pixi-map\",\n \"map-resource-fallback\",\n \"map-reduced-motion\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n mapReducedMotion,\n mapResourceFallback,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n if (server) {\n await server.stop();\n server = null;\n }\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n if (server) {\n await server.stop();\n server = null;\n }\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to sqlite.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getImperialExamArchivePanelFailures,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getMapPanelFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "37b350497148c260b22d5729e175c85531a18470": {"t": 1785753935.521127, "n": 172044, "h": "51cb8a339b9bfe5f", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst mapCanvasSampleGridSize = 12;\nconst mapCanvasMinimumChangedSamples = 6;\nconst mapMaxAnimatedEffects = 40;\nconst DEFAULT_SMOKE_STORAGE_ADAPTER = \"sqlite\";\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createSqliteSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || DEFAULT_SMOKE_STORAGE_ADAPTER).trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || DEFAULT_SMOKE_STORAGE_ADAPTER);\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelScrollWidth > metrics.mapPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map panel has horizontal scroll overflow (${roundMetric(metrics.mapPanelScrollWidth)}px > ${roundMetric(metrics.mapPanelClientWidth)}px).`\n );\n }\n\n if (metrics.mapUiLayerClientWidth > 0 && metrics.mapUiLayerScrollWidth > metrics.mapUiLayerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map UI layer has horizontal scroll overflow (${roundMetric(metrics.mapUiLayerScrollWidth)}px > ${roundMetric(metrics.mapUiLayerClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveClientWidth > 0 && metrics.imperialExamArchiveScrollWidth > metrics.imperialExamArchiveClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive panel has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveGridClientWidth > 0 && metrics.imperialExamArchiveGridScrollWidth > metrics.imperialExamArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive grid has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveGridScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelHeight < 180) {\n failures.push(`${mode} map panel is too short (${roundMetric(metrics.mapPanelHeight)}px).`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const mapPanel = box(\"#map-panel\");\n const mapUiLayer = box(\"#map-ui-layer\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const imperialExamArchive = box(\"#imperial-exam-archive-panel\");\n const imperialExamArchiveGrid = box(\"#imperial-exam-archive-panel .imperial-exam-archive-grid\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n mapPanelClientWidth: mapPanel?.clientWidth || 0,\n mapPanelHeight: mapPanel?.height || 0,\n mapPanelScrollWidth: mapPanel?.scrollWidth || 0,\n mapPanelWidth: mapPanel?.width || 0,\n mapUiLayerClientWidth: mapUiLayer?.clientWidth || 0,\n mapUiLayerScrollWidth: mapUiLayer?.scrollWidth || 0,\n mapUiLayerWidth: mapUiLayer?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n imperialExamArchiveClientWidth: imperialExamArchive?.clientWidth || 0,\n imperialExamArchiveScrollWidth: imperialExamArchive?.scrollWidth || 0,\n imperialExamArchiveWidth: imperialExamArchive?.width || 0,\n imperialExamArchiveGridClientWidth: imperialExamArchiveGrid?.clientWidth || 0,\n imperialExamArchiveGridScrollWidth: imperialExamArchiveGrid?.scrollWidth || 0,\n imperialExamArchiveGridWidth: imperialExamArchiveGrid?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst imperialExamArchiveSections = [\"study\", \"procedure\", \"ranking\", \"network\", \"appointment\"];\n\nfunction getImperialExamArchivePanelFailures(snapshot = {}, expectations = {}, mode = \"imperial exam archive\") {\n const failures = [];\n const expectedSections = expectations.expectedSections || imperialExamArchiveSections;\n const availableSections = new Set(snapshot.sections || []);\n const missingSections = expectedSections.filter((section) => !availableSections.has(section));\n if (missingSections.length) {\n failures.push(`${mode} panel is missing sections: ${missingSections.join(\", \")}.`);\n }\n\n const sourceViews = new Set(snapshot.sourceViews || []);\n const expectedSourceViews = expectations.expectedSourceViews || [\n \"studyProfileView\",\n \"examProcedureView+examinerPanelView\",\n \"examHonorView+examHistory\",\n \"relationshipView+worldPeopleView\",\n \"appointmentTrackView\"\n ];\n const missingSourceViews = expectedSourceViews.filter((sourceView) => !sourceViews.has(sourceView));\n if (missingSourceViews.length) {\n failures.push(`${mode} panel is missing source views: ${missingSourceViews.join(\", \")}.`);\n }\n\n if (Number(snapshot.cardCount || 0) < expectedSections.length) {\n failures.push(`${mode} panel rendered ${snapshot.cardCount || 0} archive cards for ${expectedSections.length} expected sections.`);\n }\n if (Number(snapshot.metricCount || 0) < (expectations.minMetricCount || expectedSections.length * 2)) {\n failures.push(`${mode} panel did not render enough visible metrics.`);\n }\n if (expectations.expectAppointment !== false && snapshot.appointmentTrackView !== \"ready\") {\n failures.push(`${mode} panel did not mark appointmentTrackView ready.`);\n }\n if (expectations.expectHistory && Number(snapshot.historyCount || 0) < expectations.expectHistory) {\n failures.push(`${mode} panel expected at least ${expectations.expectHistory} exam history rows, got ${snapshot.historyCount || 0}.`);\n }\n if (expectations.minRankingRows && Number(snapshot.rankingRows || 0) < expectations.minRankingRows) {\n failures.push(`${mode} panel rendered too few ranking rows.`);\n }\n if (expectations.minNetworkContacts && Number(snapshot.networkContactCount || 0) < expectations.minNetworkContacts) {\n failures.push(`${mode} panel rendered too few network contact rows.`);\n }\n\n const defaultHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"examiner_reviews\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"raw audit\",\n \"raw proposal\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertImperialExamArchivePanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#imperial-exam-archive-panel\", `${mode} imperial exam archive panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#imperial-exam-archive-panel\");\n const cards = [...document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-archive-card\")];\n return {\n studyProfileView: panel?.dataset.studyProfileView || \"\",\n examProcedureView: panel?.dataset.examProcedureView || \"\",\n examinerPanelView: panel?.dataset.examinerPanelView || \"\",\n examHonorView: panel?.dataset.examHonorView || \"\",\n relationshipView: panel?.dataset.relationshipView || \"\",\n worldPeopleView: panel?.dataset.worldPeopleView || \"\",\n appointmentTrackView: panel?.dataset.appointmentTrackView || \"\",\n historyCount: panel?.dataset.historyCount || \"0\",\n cardCount: cards.length,\n sections: cards.map((card) => card.dataset.section).filter(Boolean),\n sourceViews: cards.map((card) => card.dataset.sourceView).filter(Boolean),\n metricCount: document.querySelectorAll(\"#imperial-exam-archive-panel .information-card-metric\").length,\n rankingRows: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-ranking li\").length,\n networkContactCount: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-network p\").length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getImperialExamArchivePanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} imperial exam archive`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nfunction getMapPanelFailures(snapshot = {}, expectations = {}, mode = \"map\") {\n const failures = [];\n const hiddenTextTokens = expectations.hiddenTextTokens || [];\n const panelText = String(snapshot.text || \"\");\n\n if (!snapshot.visible) {\n failures.push(`${mode} map panel is not visible.`);\n }\n if (!snapshot.canvasCount) {\n failures.push(`${mode} map panel did not mount a PixiJS canvas.`);\n }\n if (snapshot.canvasWidth <= 0 || snapshot.canvasHeight <= 0) {\n failures.push(`${mode} map canvas has no drawable size.`);\n }\n if (!snapshot.canvasNonBlank) {\n failures.push(`${mode} map canvas appears blank.`);\n }\n if (snapshot.labelCount < (expectations.minLabels || 1)) {\n failures.push(`${mode} map panel rendered too few labels (${snapshot.labelCount}).`);\n }\n if (snapshot.tooltipOutOfBounds) {\n failures.push(`${mode} map tooltip overflows the map panel.`);\n }\n if (snapshot.debug?.renderer?.animatedEffectCount > mapMaxAnimatedEffects) {\n failures.push(`${mode} map animated effect count exceeds cap (${snapshot.debug.renderer.animatedEffectCount}).`);\n }\n if (expectations.expectFallbackAssetMode && snapshot.debug?.renderer?.assetMode !== \"fallback\") {\n failures.push(`${mode} map did not enter fallback asset mode.`);\n }\n if (expectations.expectReducedMotion && !snapshot.debug?.renderer?.reducedMotion) {\n failures.push(`${mode} map renderer did not observe prefers-reduced-motion.`);\n }\n if (expectations.expectReducedMotion && snapshot.debug?.renderer?.animatedEffectCount !== 0) {\n failures.push(`${mode} map renderer created animations while reduced motion was requested.`);\n }\n if (expectations.expectPixiFallbackText && !/舆图组件暂缺|舆图资料待生成/.test(panelText)) {\n failures.push(`${mode} map did not render the static PixiJS fallback text.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && panelText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${mode} map panel leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function readMapPanelSnapshot(page) {\n return page.evaluate(({ sampleGridSize, minimumChangedSamples }) => {\n const panel = document.querySelector(\"#map-panel\");\n const canvas = document.querySelector(\"#map-canvas canvas\");\n const panelRect = panel?.getBoundingClientRect();\n let canvasNonBlank = false;\n let changedSamples = 0;\n let canvasWidth = 0;\n let canvasHeight = 0;\n\n if (canvas) {\n canvasWidth = canvas.width;\n canvasHeight = canvas.height;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n canvasNonBlank = changedSamples >= minimumChangedSamples;\n } catch (error) {\n canvasNonBlank = false;\n }\n }\n\n const tooltip = document.querySelector(\"#map-ui-layer .map-tooltip\");\n const tooltipRect = tooltip?.getBoundingClientRect();\n const tooltipOutOfBounds = Boolean(\n panelRect &&\n tooltipRect &&\n (\n tooltipRect.left < panelRect.left - 2 ||\n tooltipRect.right > panelRect.right + 2 ||\n tooltipRect.top < panelRect.top - 2 ||\n tooltipRect.bottom > panelRect.bottom + 2\n )\n );\n\n return {\n canvasCount: document.querySelectorAll(\"#map-canvas canvas\").length,\n canvasHeight,\n canvasNonBlank,\n canvasWidth,\n changedSamples,\n debug: window.QianqiuMapRenderer?.getDebugState?.() || null,\n labelCount: document.querySelectorAll(\"#map-ui-layer .map-label\").length,\n text: panel?.innerText || \"\",\n tooltipOutOfBounds,\n visible: Boolean(panel && !panel.hidden && getComputedStyle(panel).display !== \"none\")\n };\n }, {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n });\n}\n\nasync function assertMapPanel(page, mode, expectations = {}) {\n await page.locator(\"#map-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n const canvas = document.querySelector(\"#map-canvas canvas\");\n return debug && (debug.renderer || debug.fallbackText) && (canvas || debug.fallbackText);\n }, null, { timeout: 10000 });\n\n if (!expectations.expectPixiFallbackText) {\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n return debug?.renderer?.assetMode === \"loaded\" || debug?.renderer?.assetMode === \"fallback\";\n }, null, { timeout: 15000 });\n }\n\n const snapshot = await readMapPanelSnapshot(page);\n const failures = getMapPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode)\n .filter((failure) => /map/.test(failure));\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function startScholarGame(page, options = {}) {\n await openCleanStartPage(page, options.baseUrl);\n await assertStartRoleOptions(page);\n await page.locator('input[name=\"dynasty\"]').fill(options.dynasty || \"Ming\");\n await page.locator('input[name=\"year\"]').fill(options.year || \"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(options.playerName || \"Browser Map Smoke\");\n await page.locator('input[name=\"background\"]').fill(options.background || \"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(options.customSetting || \"map browser acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n return sessionId;\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertImperialExamArchivePanel(page, \"complete exam progression official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertMapPanel(page, \"mobile map\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"mobile scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMapResourceFailureAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n try {\n await context.route(\"**/assets/maps/ink-map-manifest.json\", (route) => route.fulfill({\n status: 404,\n contentType: \"application/json\",\n body: \"{\\\"error\\\":\\\"missing manifest for fallback smoke\\\"}\"\n }));\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Fallback\",\n customSetting: \"map resource failure acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map resource fallback\");\n const snapshot = await assertMapPanel(page, \"map resource fallback\", {\n expectFallbackAssetMode: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n assetMode: snapshot.debug?.renderer?.assetMode || \"unknown\",\n markerCount: snapshot.debug?.renderer?.markerCount || 0\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runMapReducedMotionAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({\n viewport: VIEWPORTS.desktop,\n reducedMotion: \"reduce\"\n });\n let sessionId = null;\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Reduced Motion\",\n customSetting: \"map reduced motion acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map reduced motion\");\n const snapshot = await assertMapPanel(page, \"map reduced motion\", {\n expectReducedMotion: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n animatedEffectCount: snapshot.debug?.renderer?.animatedEffectCount || 0,\n reducedMotion: Boolean(snapshot.debug?.renderer?.reducedMotion)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await assertImperialExamArchivePanel(page, \"mobile post-palace official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertMapPanel(page, \"desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar\", {\n expectAppointment: false,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after turn\", {\n expectAppointment: false,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after exam\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertMapPanel(page, \"desktop restored map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop restored scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertMapPanel(freshPage, \"fresh page desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(freshPage, \"fresh page desktop scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const mapResourceFallback = await runMapResourceFailureAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n const mapReducedMotion = await runMapReducedMotionAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\",\n \"pixi-map\",\n \"map-resource-fallback\",\n \"map-reduced-motion\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n mapReducedMotion,\n mapResourceFallback,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to sqlite.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getImperialExamArchivePanelFailures,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getMapPanelFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "2912750bd406bc4fa0c3737fccb0ba93b41b1b3b": {"t": 1785753938.1783504, "n": 171923, "h": "2d068e8f8a005ae3", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst mapCanvasSampleGridSize = 12;\nconst mapCanvasMinimumChangedSamples = 6;\nconst mapMaxAnimatedEffects = 40;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelScrollWidth > metrics.mapPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map panel has horizontal scroll overflow (${roundMetric(metrics.mapPanelScrollWidth)}px > ${roundMetric(metrics.mapPanelClientWidth)}px).`\n );\n }\n\n if (metrics.mapUiLayerClientWidth > 0 && metrics.mapUiLayerScrollWidth > metrics.mapUiLayerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} map UI layer has horizontal scroll overflow (${roundMetric(metrics.mapUiLayerScrollWidth)}px > ${roundMetric(metrics.mapUiLayerClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveClientWidth > 0 && metrics.imperialExamArchiveScrollWidth > metrics.imperialExamArchiveClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive panel has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveGridClientWidth > 0 && metrics.imperialExamArchiveGridScrollWidth > metrics.imperialExamArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive grid has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveGridScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n if (metrics.mapPanelClientWidth > 0 && metrics.mapPanelHeight < 180) {\n failures.push(`${mode} map panel is too short (${roundMetric(metrics.mapPanelHeight)}px).`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const mapPanel = box(\"#map-panel\");\n const mapUiLayer = box(\"#map-ui-layer\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const imperialExamArchive = box(\"#imperial-exam-archive-panel\");\n const imperialExamArchiveGrid = box(\"#imperial-exam-archive-panel .imperial-exam-archive-grid\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n mapPanelClientWidth: mapPanel?.clientWidth || 0,\n mapPanelHeight: mapPanel?.height || 0,\n mapPanelScrollWidth: mapPanel?.scrollWidth || 0,\n mapPanelWidth: mapPanel?.width || 0,\n mapUiLayerClientWidth: mapUiLayer?.clientWidth || 0,\n mapUiLayerScrollWidth: mapUiLayer?.scrollWidth || 0,\n mapUiLayerWidth: mapUiLayer?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n imperialExamArchiveClientWidth: imperialExamArchive?.clientWidth || 0,\n imperialExamArchiveScrollWidth: imperialExamArchive?.scrollWidth || 0,\n imperialExamArchiveWidth: imperialExamArchive?.width || 0,\n imperialExamArchiveGridClientWidth: imperialExamArchiveGrid?.clientWidth || 0,\n imperialExamArchiveGridScrollWidth: imperialExamArchiveGrid?.scrollWidth || 0,\n imperialExamArchiveGridWidth: imperialExamArchiveGrid?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst imperialExamArchiveSections = [\"study\", \"procedure\", \"ranking\", \"network\", \"appointment\"];\n\nfunction getImperialExamArchivePanelFailures(snapshot = {}, expectations = {}, mode = \"imperial exam archive\") {\n const failures = [];\n const expectedSections = expectations.expectedSections || imperialExamArchiveSections;\n const availableSections = new Set(snapshot.sections || []);\n const missingSections = expectedSections.filter((section) => !availableSections.has(section));\n if (missingSections.length) {\n failures.push(`${mode} panel is missing sections: ${missingSections.join(\", \")}.`);\n }\n\n const sourceViews = new Set(snapshot.sourceViews || []);\n const expectedSourceViews = expectations.expectedSourceViews || [\n \"studyProfileView\",\n \"examProcedureView+examinerPanelView\",\n \"examHonorView+examHistory\",\n \"relationshipView+worldPeopleView\",\n \"appointmentTrackView\"\n ];\n const missingSourceViews = expectedSourceViews.filter((sourceView) => !sourceViews.has(sourceView));\n if (missingSourceViews.length) {\n failures.push(`${mode} panel is missing source views: ${missingSourceViews.join(\", \")}.`);\n }\n\n if (Number(snapshot.cardCount || 0) < expectedSections.length) {\n failures.push(`${mode} panel rendered ${snapshot.cardCount || 0} archive cards for ${expectedSections.length} expected sections.`);\n }\n if (Number(snapshot.metricCount || 0) < (expectations.minMetricCount || expectedSections.length * 2)) {\n failures.push(`${mode} panel did not render enough visible metrics.`);\n }\n if (expectations.expectAppointment !== false && snapshot.appointmentTrackView !== \"ready\") {\n failures.push(`${mode} panel did not mark appointmentTrackView ready.`);\n }\n if (expectations.expectHistory && Number(snapshot.historyCount || 0) < expectations.expectHistory) {\n failures.push(`${mode} panel expected at least ${expectations.expectHistory} exam history rows, got ${snapshot.historyCount || 0}.`);\n }\n if (expectations.minRankingRows && Number(snapshot.rankingRows || 0) < expectations.minRankingRows) {\n failures.push(`${mode} panel rendered too few ranking rows.`);\n }\n if (expectations.minNetworkContacts && Number(snapshot.networkContactCount || 0) < expectations.minNetworkContacts) {\n failures.push(`${mode} panel rendered too few network contact rows.`);\n }\n\n const defaultHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"examiner_reviews\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"raw audit\",\n \"raw proposal\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertImperialExamArchivePanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#imperial-exam-archive-panel\", `${mode} imperial exam archive panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#imperial-exam-archive-panel\");\n const cards = [...document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-archive-card\")];\n return {\n studyProfileView: panel?.dataset.studyProfileView || \"\",\n examProcedureView: panel?.dataset.examProcedureView || \"\",\n examinerPanelView: panel?.dataset.examinerPanelView || \"\",\n examHonorView: panel?.dataset.examHonorView || \"\",\n relationshipView: panel?.dataset.relationshipView || \"\",\n worldPeopleView: panel?.dataset.worldPeopleView || \"\",\n appointmentTrackView: panel?.dataset.appointmentTrackView || \"\",\n historyCount: panel?.dataset.historyCount || \"0\",\n cardCount: cards.length,\n sections: cards.map((card) => card.dataset.section).filter(Boolean),\n sourceViews: cards.map((card) => card.dataset.sourceView).filter(Boolean),\n metricCount: document.querySelectorAll(\"#imperial-exam-archive-panel .information-card-metric\").length,\n rankingRows: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-ranking li\").length,\n networkContactCount: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-network p\").length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getImperialExamArchivePanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} imperial exam archive`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nfunction getMapPanelFailures(snapshot = {}, expectations = {}, mode = \"map\") {\n const failures = [];\n const hiddenTextTokens = expectations.hiddenTextTokens || [];\n const panelText = String(snapshot.text || \"\");\n\n if (!snapshot.visible) {\n failures.push(`${mode} map panel is not visible.`);\n }\n if (!snapshot.canvasCount) {\n failures.push(`${mode} map panel did not mount a PixiJS canvas.`);\n }\n if (snapshot.canvasWidth <= 0 || snapshot.canvasHeight <= 0) {\n failures.push(`${mode} map canvas has no drawable size.`);\n }\n if (!snapshot.canvasNonBlank) {\n failures.push(`${mode} map canvas appears blank.`);\n }\n if (snapshot.labelCount < (expectations.minLabels || 1)) {\n failures.push(`${mode} map panel rendered too few labels (${snapshot.labelCount}).`);\n }\n if (snapshot.tooltipOutOfBounds) {\n failures.push(`${mode} map tooltip overflows the map panel.`);\n }\n if (snapshot.debug?.renderer?.animatedEffectCount > mapMaxAnimatedEffects) {\n failures.push(`${mode} map animated effect count exceeds cap (${snapshot.debug.renderer.animatedEffectCount}).`);\n }\n if (expectations.expectFallbackAssetMode && snapshot.debug?.renderer?.assetMode !== \"fallback\") {\n failures.push(`${mode} map did not enter fallback asset mode.`);\n }\n if (expectations.expectReducedMotion && !snapshot.debug?.renderer?.reducedMotion) {\n failures.push(`${mode} map renderer did not observe prefers-reduced-motion.`);\n }\n if (expectations.expectReducedMotion && snapshot.debug?.renderer?.animatedEffectCount !== 0) {\n failures.push(`${mode} map renderer created animations while reduced motion was requested.`);\n }\n if (expectations.expectPixiFallbackText && !/舆图组件暂缺|舆图资料待生成/.test(panelText)) {\n failures.push(`${mode} map did not render the static PixiJS fallback text.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && panelText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${mode} map panel leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function readMapPanelSnapshot(page) {\n return page.evaluate(({ sampleGridSize, minimumChangedSamples }) => {\n const panel = document.querySelector(\"#map-panel\");\n const canvas = document.querySelector(\"#map-canvas canvas\");\n const panelRect = panel?.getBoundingClientRect();\n let canvasNonBlank = false;\n let changedSamples = 0;\n let canvasWidth = 0;\n let canvasHeight = 0;\n\n if (canvas) {\n canvasWidth = canvas.width;\n canvasHeight = canvas.height;\n try {\n const sampleCanvas = document.createElement(\"canvas\");\n sampleCanvas.width = sampleGridSize;\n sampleCanvas.height = sampleGridSize;\n const context = sampleCanvas.getContext(\"2d\", { willReadFrequently: true });\n context.drawImage(canvas, 0, 0, sampleGridSize, sampleGridSize);\n const pixels = context.getImageData(0, 0, sampleGridSize, sampleGridSize).data;\n for (let index = 0; index < pixels.length; index += 4) {\n const alpha = pixels[index + 3];\n const red = pixels[index];\n const green = pixels[index + 1];\n const blue = pixels[index + 2];\n if (alpha > 8 && (red < 246 || green < 246 || blue < 246)) {\n changedSamples += 1;\n }\n }\n canvasNonBlank = changedSamples >= minimumChangedSamples;\n } catch (error) {\n canvasNonBlank = false;\n }\n }\n\n const tooltip = document.querySelector(\"#map-ui-layer .map-tooltip\");\n const tooltipRect = tooltip?.getBoundingClientRect();\n const tooltipOutOfBounds = Boolean(\n panelRect &&\n tooltipRect &&\n (\n tooltipRect.left < panelRect.left - 2 ||\n tooltipRect.right > panelRect.right + 2 ||\n tooltipRect.top < panelRect.top - 2 ||\n tooltipRect.bottom > panelRect.bottom + 2\n )\n );\n\n return {\n canvasCount: document.querySelectorAll(\"#map-canvas canvas\").length,\n canvasHeight,\n canvasNonBlank,\n canvasWidth,\n changedSamples,\n debug: window.QianqiuMapRenderer?.getDebugState?.() || null,\n labelCount: document.querySelectorAll(\"#map-ui-layer .map-label\").length,\n text: panel?.innerText || \"\",\n tooltipOutOfBounds,\n visible: Boolean(panel && !panel.hidden && getComputedStyle(panel).display !== \"none\")\n };\n }, {\n minimumChangedSamples: mapCanvasMinimumChangedSamples,\n sampleGridSize: mapCanvasSampleGridSize\n });\n}\n\nasync function assertMapPanel(page, mode, expectations = {}) {\n await page.locator(\"#map-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n const canvas = document.querySelector(\"#map-canvas canvas\");\n return debug && (debug.renderer || debug.fallbackText) && (canvas || debug.fallbackText);\n }, null, { timeout: 10000 });\n\n if (!expectations.expectPixiFallbackText) {\n await page.waitForFunction(() => {\n const debug = window.QianqiuMapRenderer?.getDebugState?.();\n return debug?.renderer?.assetMode === \"loaded\" || debug?.renderer?.assetMode === \"fallback\";\n }, null, { timeout: 15000 });\n }\n\n const snapshot = await readMapPanelSnapshot(page);\n const failures = getMapPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode)\n .filter((failure) => /map/.test(failure));\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function startScholarGame(page, options = {}) {\n await openCleanStartPage(page, options.baseUrl);\n await assertStartRoleOptions(page);\n await page.locator('input[name=\"dynasty\"]').fill(options.dynasty || \"Ming\");\n await page.locator('input[name=\"year\"]').fill(options.year || \"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(options.playerName || \"Browser Map Smoke\");\n await page.locator('input[name=\"background\"]').fill(options.background || \"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(options.customSetting || \"map browser acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n return sessionId;\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertImperialExamArchivePanel(page, \"complete exam progression official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertMapPanel(page, \"mobile map\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"mobile scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMapResourceFailureAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n try {\n await context.route(\"**/assets/maps/ink-map-manifest.json\", (route) => route.fulfill({\n status: 404,\n contentType: \"application/json\",\n body: \"{\\\"error\\\":\\\"missing manifest for fallback smoke\\\"}\"\n }));\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Fallback\",\n customSetting: \"map resource failure acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map resource fallback\");\n const snapshot = await assertMapPanel(page, \"map resource fallback\", {\n expectFallbackAssetMode: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n assetMode: snapshot.debug?.renderer?.assetMode || \"unknown\",\n markerCount: snapshot.debug?.renderer?.markerCount || 0\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runMapReducedMotionAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({\n viewport: VIEWPORTS.desktop,\n reducedMotion: \"reduce\"\n });\n let sessionId = null;\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n sessionId = await startScholarGame(page, {\n baseUrl,\n playerName: \"Browser Map Reduced Motion\",\n customSetting: \"map reduced motion acceptance run\"\n });\n onSessionId(sessionId);\n await assertGameLayout(page, \"map reduced motion\");\n const snapshot = await assertMapPanel(page, \"map reduced motion\", {\n expectReducedMotion: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n return {\n sessionId,\n animatedEffectCount: snapshot.debug?.renderer?.animatedEffectCount || 0,\n reducedMotion: Boolean(snapshot.debug?.renderer?.reducedMotion)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await assertImperialExamArchivePanel(page, \"mobile post-palace official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertMapPanel(page, \"desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar\", {\n expectAppointment: false,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after turn\", {\n expectAppointment: false,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after exam\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertMapPanel(page, \"desktop restored map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop restored scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertMapPanel(freshPage, \"fresh page desktop map\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\", \"world_sessions\", \"prompt_retrieval_index\"]\n });\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(freshPage, \"fresh page desktop scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const mapResourceFallback = await runMapResourceFailureAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n const mapReducedMotion = await runMapReducedMotionAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\",\n \"pixi-map\",\n \"map-resource-fallback\",\n \"map-reduced-motion\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n mapReducedMotion,\n mapResourceFallback,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getImperialExamArchivePanelFailures,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getMapPanelFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "a4f4e8d00fa8d77c5925b06a42c1692a4a8046d3": {"t": 1785753940.979295, "n": 159779, "h": "484de292b0247df4", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveClientWidth > 0 && metrics.imperialExamArchiveScrollWidth > metrics.imperialExamArchiveClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive panel has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveClientWidth)}px).`\n );\n }\n\n if (metrics.imperialExamArchiveGridClientWidth > 0 && metrics.imperialExamArchiveGridScrollWidth > metrics.imperialExamArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} imperial exam archive grid has horizontal scroll overflow (${roundMetric(metrics.imperialExamArchiveGridScrollWidth)}px > ${roundMetric(metrics.imperialExamArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const imperialExamArchive = box(\"#imperial-exam-archive-panel\");\n const imperialExamArchiveGrid = box(\"#imperial-exam-archive-panel .imperial-exam-archive-grid\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n imperialExamArchiveClientWidth: imperialExamArchive?.clientWidth || 0,\n imperialExamArchiveScrollWidth: imperialExamArchive?.scrollWidth || 0,\n imperialExamArchiveWidth: imperialExamArchive?.width || 0,\n imperialExamArchiveGridClientWidth: imperialExamArchiveGrid?.clientWidth || 0,\n imperialExamArchiveGridScrollWidth: imperialExamArchiveGrid?.scrollWidth || 0,\n imperialExamArchiveGridWidth: imperialExamArchiveGrid?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst imperialExamArchiveSections = [\"study\", \"procedure\", \"ranking\", \"network\", \"appointment\"];\n\nfunction getImperialExamArchivePanelFailures(snapshot = {}, expectations = {}, mode = \"imperial exam archive\") {\n const failures = [];\n const expectedSections = expectations.expectedSections || imperialExamArchiveSections;\n const availableSections = new Set(snapshot.sections || []);\n const missingSections = expectedSections.filter((section) => !availableSections.has(section));\n if (missingSections.length) {\n failures.push(`${mode} panel is missing sections: ${missingSections.join(\", \")}.`);\n }\n\n const sourceViews = new Set(snapshot.sourceViews || []);\n const expectedSourceViews = expectations.expectedSourceViews || [\n \"studyProfileView\",\n \"examProcedureView+examinerPanelView\",\n \"examHonorView+examHistory\",\n \"relationshipView+worldPeopleView\",\n \"appointmentTrackView\"\n ];\n const missingSourceViews = expectedSourceViews.filter((sourceView) => !sourceViews.has(sourceView));\n if (missingSourceViews.length) {\n failures.push(`${mode} panel is missing source views: ${missingSourceViews.join(\", \")}.`);\n }\n\n if (Number(snapshot.cardCount || 0) < expectedSections.length) {\n failures.push(`${mode} panel rendered ${snapshot.cardCount || 0} archive cards for ${expectedSections.length} expected sections.`);\n }\n if (Number(snapshot.metricCount || 0) < (expectations.minMetricCount || expectedSections.length * 2)) {\n failures.push(`${mode} panel did not render enough visible metrics.`);\n }\n if (expectations.expectAppointment !== false && snapshot.appointmentTrackView !== \"ready\") {\n failures.push(`${mode} panel did not mark appointmentTrackView ready.`);\n }\n if (expectations.expectHistory && Number(snapshot.historyCount || 0) < expectations.expectHistory) {\n failures.push(`${mode} panel expected at least ${expectations.expectHistory} exam history rows, got ${snapshot.historyCount || 0}.`);\n }\n if (expectations.minRankingRows && Number(snapshot.rankingRows || 0) < expectations.minRankingRows) {\n failures.push(`${mode} panel rendered too few ranking rows.`);\n }\n if (expectations.minNetworkContacts && Number(snapshot.networkContactCount || 0) < expectations.minNetworkContacts) {\n failures.push(`${mode} panel rendered too few network contact rows.`);\n }\n\n const defaultHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"examiner_reviews\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"raw audit\",\n \"raw proposal\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertImperialExamArchivePanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#imperial-exam-archive-panel\", `${mode} imperial exam archive panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#imperial-exam-archive-panel\");\n const cards = [...document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-archive-card\")];\n return {\n studyProfileView: panel?.dataset.studyProfileView || \"\",\n examProcedureView: panel?.dataset.examProcedureView || \"\",\n examinerPanelView: panel?.dataset.examinerPanelView || \"\",\n examHonorView: panel?.dataset.examHonorView || \"\",\n relationshipView: panel?.dataset.relationshipView || \"\",\n worldPeopleView: panel?.dataset.worldPeopleView || \"\",\n appointmentTrackView: panel?.dataset.appointmentTrackView || \"\",\n historyCount: panel?.dataset.historyCount || \"0\",\n cardCount: cards.length,\n sections: cards.map((card) => card.dataset.section).filter(Boolean),\n sourceViews: cards.map((card) => card.dataset.sourceView).filter(Boolean),\n metricCount: document.querySelectorAll(\"#imperial-exam-archive-panel .information-card-metric\").length,\n rankingRows: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-ranking li\").length,\n networkContactCount: document.querySelectorAll(\"#imperial-exam-archive-panel .imperial-exam-network p\").length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getImperialExamArchivePanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} imperial exam archive`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertImperialExamArchivePanel(page, \"complete exam progression official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"mobile scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await assertImperialExamArchivePanel(page, \"mobile post-palace official\", {\n expectAppointment: true,\n expectHistory: 4,\n minRankingRows: 1,\n minNetworkContacts: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar\", {\n expectAppointment: false,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after turn\", {\n expectAppointment: false,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertImperialExamArchivePanel(page, \"desktop scholar after exam\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(page, \"desktop restored scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await assertImperialExamArchivePanel(freshPage, \"fresh page desktop scholar\", {\n expectAppointment: false,\n expectHistory: 1,\n minRankingRows: 1,\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getImperialExamArchivePanelFailures,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "efa5fa114258f10097ffe8ac7f6e20b029059d80": {"t": 1785753943.6928298, "n": 150967, "h": "7b815b122fde9b5b", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 45000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelAvailableFilters(snapshot = {}, tabId, visibleValues = []) {\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n const page = panelPages.find((entry) => entry?.tabId === tabId) || {};\n return uniqueSorted([\n ...(visibleValues || []),\n ...(Array.isArray(page.filterOptions) ? page.filterOptions : [])\n ]);\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-geography\", snapshot.worldGeographyKinds),\n expectedWorldGeographyKinds\n );\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"posting-geography\", snapshot.postingGeographyKinds),\n expectedPostingGeographyKinds\n );\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"world-people\", snapshot.worldPeopleKinds),\n expectedWorldPeopleKinds\n );\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"official-postings\", snapshot.officialPostingKinds),\n expectedOfficialPostingKinds\n );\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(\n getInformationPanelAvailableFilters(snapshot, \"event-archive\", snapshot.eventArchiveSourceTypes),\n expectedEventArchiveSourceTypes\n );\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n if (expectations.expectInformationControls !== false) {\n const controls = snapshot.informationControls || {};\n const controlCount = Number(controls.controlCount || 0);\n const searchCount = Number(controls.searchCount || 0);\n const filterCount = Number(controls.filterCount || 0);\n const sortCount = Number(controls.sortCount || 0);\n const pagerCount = Number(controls.pagerCount || 0);\n const expectedControlCount = expectations.expectedInformationControlCount || expectedPanels.length;\n if (controlCount < expectedControlCount || searchCount < expectedControlCount || filterCount < expectedControlCount || sortCount < expectedControlCount || pagerCount < expectedControlCount) {\n failures.push(`${mode} information panel did not expose search/filter/sort/pagination controls for every panel.`);\n }\n }\n\n const panelPages = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n if (expectations.expectInformationPageMetadata !== false) {\n const missingMetadata = panelPages.filter((page) =>\n !page.tabId ||\n Number(page.page) < 1 ||\n Number(page.pageSize) < 1 ||\n Number(page.totalItems) < 0 ||\n Number(page.totalPages) < 1 ||\n Number(page.pageItemCount) < 0 ||\n !page.filter ||\n !page.sort\n );\n if (missingMetadata.length) {\n failures.push(`${mode} information panel did not expose page metadata for panels: ${missingMetadata.map((page) => page.id || page.tabId || \"unknown\").join(\", \")}.`);\n }\n const mismatchedCards = panelPages.filter((page) => Number(page.visibleCards || 0) !== Number(page.pageItemCount || 0));\n if (mismatchedCards.length) {\n failures.push(`${mode} information panel page item counts do not match rendered cards for panels: ${mismatchedCards.map((page) => page.tabId).join(\", \")}.`);\n }\n }\n\n if (expectations.expectedPageSize) {\n const wrongPageSize = panelPages.filter((page) =>\n page.tabId === snapshot.activeTab && Number(page.pageSize) !== expectations.expectedPageSize\n );\n if (wrongPageSize.length) {\n failures.push(`${mode} information panel expected active page size ${expectations.expectedPageSize}, got ${wrongPageSize[0].pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n const controls = [...document.querySelectorAll(\"#information-panel .information-controls\")];\n const visibleCardsForPage = (page) => page.querySelectorAll(\n \".world-geography-card, .posting-geography-card, .world-people-card, .official-posting-card, .event-archive-item\"\n ).length;\n const filterOptionsForPage = (page) => {\n const control = controls.find((entry) => entry.dataset.tabId === page.dataset.tabId);\n if (!control) return [];\n const filterSelect = control.querySelector(\"select[aria-label$='筛选']\");\n if (!filterSelect) return [];\n return [...filterSelect.options].map((option) => option.value).filter(Boolean);\n };\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n informationControls: {\n controlCount: controls.length,\n searchCount: document.querySelectorAll(\"#information-panel .information-search\").length,\n filterCount: document.querySelectorAll(\"#information-panel .information-controls .information-filter\").length,\n sortCount: document.querySelectorAll(\"#information-panel .information-controls select[aria-label$='排序']\").length,\n pagerCount: document.querySelectorAll(\"#information-panel .information-pagination\").length\n },\n panelPageMetadata: pages.map((page) => ({\n id: page.id,\n tabId: page.dataset.tabId || \"\",\n query: page.dataset.query || \"\",\n filter: page.dataset.filter || \"\",\n sort: page.dataset.sort || \"\",\n page: page.dataset.page || \"\",\n pageSize: page.dataset.pageSize || \"\",\n totalItems: page.dataset.totalItems || \"\",\n totalPages: page.dataset.totalPages || \"\",\n pageItemCount: page.dataset.pageItemCount || \"\",\n queryRejected: page.dataset.queryRejected || \"\",\n visibleCards: visibleCardsForPage(page),\n filterOptions: filterOptionsForPage(page)\n })),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n const metadata = Array.isArray(snapshot.panelPageMetadata) ? snapshot.panelPageMetadata : [];\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n informationControls: {\n controlCount: normalizeNumeric(snapshot.informationControls?.controlCount),\n searchCount: normalizeNumeric(snapshot.informationControls?.searchCount),\n filterCount: normalizeNumeric(snapshot.informationControls?.filterCount),\n sortCount: normalizeNumeric(snapshot.informationControls?.sortCount),\n pagerCount: normalizeNumeric(snapshot.informationControls?.pagerCount)\n },\n panelPageMetadata: metadata.map((page) => ({\n tabId: page.tabId || \"\",\n filter: page.filter || \"\",\n sort: page.sort || \"\",\n page: normalizeNumeric(page.page),\n pageSize: normalizeNumeric(page.pageSize),\n totalItems: normalizeNumeric(page.totalItems),\n totalPages: normalizeNumeric(page.totalPages),\n pageItemCount: normalizeNumeric(page.pageItemCount),\n visibleCards: normalizeNumeric(page.visibleCards),\n filterOptions: uniqueSorted(page.filterOptions || [])\n })).sort((first, second) => first.tabId.localeCompare(second.tabId)),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}&informationTab=event-archive&informationPage=1&informationPageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n const informationPage = payload.informationPanelPageView?.activePage || {};\n if (informationPage.tabId !== \"event-archive\") {\n throw new Error(`Paged information panel expected event-archive tab, got ${informationPage.tabId || \"none\"}.`);\n }\n if (informationPage.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged information panel expected page size ${pageSize}, got ${informationPage.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(informationPage, \"items\").length > pageSize) {\n throw new Error(`Paged information panel rendered more than ${pageSize} items.`);\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "9ae7da1f84d84d4ef388d30b034b7a4d77f1a04a": {"t": 1785753945.5704656, "n": 144301, "h": "83163082585fb0ae", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n const joinWinPath = (...parts) => path.win32.join(...parts);\n return [\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n joinWinPath(\n env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\",\n \"Microsoft\",\n \"Edge\",\n \"Application\",\n \"msedge.exe\"\n ),\n joinWinPath(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(snapshot.eventArchiveSourceTypes, expectedEventArchiveSourceTypes);\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "e12d5f0b2f586f9ba7390fd5b129da49764a0f54": {"t": 1785753947.325054, "n": 144182, "h": "e49792e75153fa17", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { randomUUID } = require(\"node:crypto\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n informationParity: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--information-parity\") {\n args.informationParity = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function removeSqliteDatabaseArtifacts(databasePath) {\n if (!databasePath || databasePath === \":memory:\") return;\n await Promise.all(\n [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`].map((filePath) =>\n fsp.rm(filePath, { force: true })\n )\n );\n}\n\nfunction resolveInformationParitySqlitePlan(storageAdapter, options = {}) {\n const ownsSqliteDatabase = storageAdapter === \"sqlite\" && !options.sqliteDatabasePath;\n const sqliteDatabasePath = storageAdapter === \"sqlite\"\n ? path.resolve(options.sqliteDatabasePath || path.join(rootDir, \"data\", `browser-information-parity-${randomUUID()}.sqlite`))\n : null;\n return { ownsSqliteDatabase, sqliteDatabasePath };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(snapshot.eventArchiveSourceTypes, expectedEventArchiveSourceTypes);\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n const pagination = snapshot.eventArchivePagination || {};\n const page = Number(pagination.page || 0);\n const pageSize = Number(pagination.pageSize || 0);\n const totalItems = Number(pagination.totalItems || 0);\n const totalPages = Number(pagination.totalPages || 0);\n const pageItemCount = Number(pagination.pageItemCount ?? itemCount);\n if (page < 1 || pageSize < 1 || totalPages < 1) {\n failures.push(`${mode} information panel did not expose event archive pagination metadata.`);\n }\n if (itemCount > 0 && pageItemCount !== itemCount) {\n failures.push(`${mode} information panel event archive pagination item count does not match rendered cards.`);\n }\n if (itemCount > 0 && totalItems < itemCount) {\n failures.push(`${mode} information panel event archive pagination total is smaller than rendered cards.`);\n }\n if (itemCount > 0 && pageSize < itemCount) {\n failures.push(`${mode} information panel event archive pagination page size is smaller than rendered cards.`);\n }\n if (expectations.expectedEventArchivePageSize && pageSize !== expectations.expectedEventArchivePageSize) {\n failures.push(`${mode} information panel expected event archive page size ${expectations.expectedEventArchivePageSize}, got ${pageSize}.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"relationshipLedger\",\n \"worldState\",\n \"world_state_json\",\n \"event_log\",\n \"ai_change_proposals\",\n \"event_archive_index\",\n \"prompt_retrieval_index\",\n \"geo_\",\n \"people_\",\n \"office_\",\n \"SQLITE_DATABASE_PATH\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n const eventArchivePanel = document.querySelector(\"#event-archive-panel\");\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n eventArchivePagination: {\n page: eventArchivePanel?.dataset.archivePage || \"\",\n pageSize: eventArchivePanel?.dataset.archivePageSize || \"\",\n totalItems: eventArchivePanel?.dataset.archiveTotalItems || \"\",\n totalPages: eventArchivePanel?.dataset.archiveTotalPages || \"\",\n hasNextPage: eventArchivePanel?.dataset.archiveHasNextPage || \"\",\n pageItemCount: eventArchivePanel?.dataset.archivePageItemCount || \"\"\n },\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nfunction uniqueSorted(values = []) {\n return [...new Set(values.filter(Boolean))].sort();\n}\n\nfunction normalizeNumeric(value) {\n const number = Number(value);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction stableJson(value) {\n if (Array.isArray(value)) return `[${value.map(stableJson).join(\",\")}]`;\n if (!value || typeof value !== \"object\") return JSON.stringify(value);\n return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(\",\")}}`;\n}\n\nfunction normalizeInformationPanelParitySnapshot(snapshot = {}) {\n const pagination = snapshot.eventArchivePagination || {};\n return {\n activeTab: snapshot.activeTab,\n tabIds: uniqueSorted(snapshot.tabIds || []),\n disabledTabIds: uniqueSorted(snapshot.disabledTabIds || []),\n panelIds: uniqueSorted(snapshot.panelIds || []),\n readyPanelIds: uniqueSorted(snapshot.readyPanelIds || []),\n sourceViews: uniqueSorted(snapshot.sourceViews || []),\n worldGeographyKinds: uniqueSorted(snapshot.worldGeographyKinds || []),\n postingGeographyKinds: uniqueSorted(snapshot.postingGeographyKinds || []),\n worldPeopleKinds: uniqueSorted(snapshot.worldPeopleKinds || []),\n officialPostingKinds: uniqueSorted(snapshot.officialPostingKinds || []),\n eventArchiveSourceTypes: uniqueSorted(snapshot.eventArchiveSourceTypes || []),\n eventArchiveStatuses: uniqueSorted(snapshot.eventArchiveStatuses || []),\n roleVisibleGeographyCount: normalizeNumeric(snapshot.roleVisibleGeographyCount),\n worldPeopleCardCount: normalizeNumeric(snapshot.worldPeopleCardCount),\n officialPostingCardCount: normalizeNumeric(snapshot.officialPostingCardCount),\n eventArchiveItemCount: normalizeNumeric(snapshot.eventArchiveItemCount),\n eventArchivePagination: {\n page: normalizeNumeric(pagination.page),\n pageSize: normalizeNumeric(pagination.pageSize),\n totalItems: normalizeNumeric(pagination.totalItems),\n totalPages: normalizeNumeric(pagination.totalPages),\n hasNextPage: String(pagination.hasNextPage || \"false\"),\n pageItemCount: normalizeNumeric(pagination.pageItemCount)\n }\n };\n}\n\nfunction normalizeRouteArray(view, key) {\n return Array.isArray(view?.[key]) ? view[key] : [];\n}\n\nfunction normalizeEventArchiveRouteView(archive = {}) {\n const items = normalizeRouteArray(archive, \"items\");\n return {\n counts: archive.counts || {},\n itemCount: items.length,\n pageCounts: archive.pageCounts || {},\n pagination: archive.pagination || {},\n sourceTypes: uniqueSorted(items.map((item) => item.sourceType)),\n statuses: uniqueSorted(items.map((item) => item.status))\n };\n}\n\nfunction normalizeInformationRoutePayload(payload = {}) {\n const geography = payload.worldGeographyView || {};\n const people = payload.worldPeopleView || {};\n const offices = payload.officialPostingsView || {};\n return {\n worldGeography: {\n countries: normalizeRouteArray(geography, \"countries\").length,\n cities: normalizeRouteArray(geography, \"cities\").length,\n routes: normalizeRouteArray(geography, \"routes\").length,\n frontierZones: normalizeRouteArray(geography, \"frontierZones\").length,\n officeJurisdictions: normalizeRouteArray(geography, \"officeJurisdictions\").length\n },\n worldPeople: {\n npcs: normalizeRouteArray(people, \"npcs\").length,\n households: normalizeRouteArray(people, \"households\").length,\n assets: normalizeRouteArray(people, \"assets\").length,\n estates: normalizeRouteArray(people, \"estates\").length,\n relationships: normalizeRouteArray(people, \"relationships\").length\n },\n officialPostings: {\n bureaus: normalizeRouteArray(offices, \"bureaus\").length,\n offices: normalizeRouteArray(offices, \"offices\").length,\n cityJurisdictions: normalizeRouteArray(offices, \"cityJurisdictions\").length,\n postings: normalizeRouteArray(offices, \"postings\").length,\n assessmentRecords: normalizeRouteArray(offices, \"assessmentRecords\").length,\n transferRecords: normalizeRouteArray(offices, \"transferRecords\").length\n },\n eventArchive: normalizeEventArchiveRouteView(payload.eventArchiveView)\n };\n}\n\nfunction pushParityDiff(failures, label, jsonValue, sqliteValue) {\n if (stableJson(jsonValue) !== stableJson(sqliteValue)) {\n failures.push(`${label} differs between JSON and SQLite storage.`);\n }\n}\n\nfunction getInformationPanelParityFailures(jsonResult = {}, sqliteResult = {}) {\n const failures = [];\n pushParityDiff(failures, \"desktop information panel snapshot\", jsonResult.informationPanel, sqliteResult.informationPanel);\n pushParityDiff(failures, \"paged information panel snapshot\", jsonResult.pagedInformationPanel, sqliteResult.pagedInformationPanel);\n pushParityDiff(failures, \"mobile information panel snapshot\", jsonResult.mobileInformationPanel, sqliteResult.mobileInformationPanel);\n pushParityDiff(failures, \"route information views\", jsonResult.routeViews, sqliteResult.routeViews);\n pushParityDiff(failures, \"paged event archive route view\", jsonResult.pagedEventArchive, sqliteResult.pagedEventArchive);\n return failures;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nasync function fetchPagedEventArchiveState(baseUrl, sessionId, pageSize = 2) {\n const response = await fetch(\n `${baseUrl}/api/game/state/${sessionId}?eventArchivePage=1&eventArchivePageSize=${pageSize}`\n );\n if (!response.ok) {\n throw new Error(`Paged event archive for ${sessionId} is not readable through the API: ${response.status}`);\n }\n const payload = await response.json();\n const archive = payload.eventArchiveView || {};\n if (archive.pagination?.pageSize !== pageSize) {\n throw new Error(`Paged event archive expected page size ${pageSize}, got ${archive.pagination?.pageSize}.`);\n }\n if (normalizeRouteArray(archive, \"items\").length > pageSize) {\n throw new Error(`Paged event archive rendered more than ${pageSize} items.`);\n }\n if ((archive.counts?.total || 0) !== archive.pagination?.totalItems) {\n throw new Error(\"Paged event archive counts total did not match pagination totalItems.\");\n }\n return payload;\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nfunction informationParityExpectations() {\n return {\n hiddenTextTokens: [\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"有人暗中遮掩亏空\",\n \"密札指向上官\",\n \"SEALED_SQLITE_PROMPT\",\n \"SEALED_SQLITE_RAW\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"data/sessions\"\n ],\n expectedEventArchiveSourceTypes: [\"event_history\", \"world_thread\", \"official_career\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n };\n}\n\nasync function runInformationPanelParityJourney(browser, { baseUrl, label, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let sessionId = null;\n\n try {\n const started = await startDirectRolePage(context, baseUrl, \"official\", \"Browser Parity\", pageErrors);\n sessionId = started.sessionId;\n onSessionId(sessionId);\n\n await submitTurnAndWaitForIdle(started.page, \"奉上官考成请求实授\", \".official-career-event\");\n await submitTurnAndWaitForIdle(started.page, \"督办赈灾与赈银核销\", \".official-career-event\");\n await started.page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(started.page, `${label} official assignment`, {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n await assertWorldThreadPanel(started.page, `${label} official assignment`, {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: informationParityExpectations().hiddenTextTokens\n });\n const informationPanel = await assertInformationPanelShell(\n started.page,\n `${label} desktop information parity`,\n informationParityExpectations()\n );\n if (recorder) await recorder.capture(started.page, `${label}-desktop-information-parity`);\n\n const routePayload = await fetchSessionState(baseUrl, sessionId);\n const pagedPayload = await fetchPagedEventArchiveState(baseUrl, sessionId, 2);\n await started.page.evaluate((payload) => {\n if (typeof window.renderPayloadWorldState !== \"function\") {\n throw new Error(\"renderPayloadWorldState is unavailable for information parity paging.\");\n }\n window.renderPayloadWorldState(payload);\n }, pagedPayload);\n const pagedInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} paged information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-paged-information-parity`);\n\n await started.page.setViewportSize(VIEWPORTS.mobile);\n await started.page.waitForTimeout(150);\n await started.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await started.page.waitForTimeout(100);\n const mobileInformationPanel = await assertInformationPanelShell(\n started.page,\n `${label} mobile information parity`,\n {\n ...informationParityExpectations(),\n expectedEventArchivePageSize: 2,\n expectedEventArchiveSourceTypes: []\n }\n );\n if (recorder) await recorder.capture(started.page, `${label}-mobile-information-parity`);\n\n return {\n baseUrl,\n sessionId,\n statusText: (await started.page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim(),\n informationPanel: normalizeInformationPanelParitySnapshot(informationPanel),\n pagedInformationPanel: normalizeInformationPanelParitySnapshot(pagedInformationPanel),\n mobileInformationPanel: normalizeInformationPanelParitySnapshot(mobileInformationPanel),\n routeViews: normalizeInformationRoutePayload(routePayload),\n pagedEventArchive: normalizeEventArchiveRouteView(pagedPayload.eventArchiveView)\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runInformationPanelParityForAdapter(browser, storageAdapter, options = {}) {\n const { ownsSqliteDatabase, sqliteDatabasePath } = resolveInformationParitySqlitePlan(storageAdapter, options);\n const sessionStore = createBrowserSmokeSessionStore({ storageAdapter, sqliteDatabasePath });\n const previousSessionStore = activeSessionStore;\n const sessionIds = [];\n let server = null;\n let result = null;\n activeSessionStore = sessionStore;\n\n try {\n server = await startLocalServer({ storageAdapter, sqliteDatabasePath });\n result = await runInformationPanelParityJourney(browser, {\n baseUrl: server.baseUrl,\n label: storageAdapter,\n onSessionId: (sessionId) => sessionIds.push(sessionId),\n pageErrors: options.pageErrors,\n recorder: options.recorder\n });\n return result;\n } finally {\n const idsToClean = new Set([result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) await server.stop();\n if (ownsSqliteDatabase) await removeSqliteDatabaseArtifacts(sqliteDatabasePath);\n }\n if (cleanupError) throw cleanupError;\n }\n}\n\nasync function runInformationPanelParitySmoke(options = {}) {\n if (options.url) {\n throw new Error(\"--information-parity starts isolated JSON and SQLite servers; do not combine it with --url.\");\n }\n if (options.storageAdapter) {\n throw new Error(\"--information-parity runs both storage adapters; do not combine it with --storage-adapter.\");\n }\n\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !options.headed\n });\n const pageErrors = [];\n const recorder = createScreenshotRecorder(options.screenshotsDir);\n\n try {\n const json = await runInformationPanelParityForAdapter(browser, \"json\", {\n pageErrors,\n recorder\n });\n const sqlite = await runInformationPanelParityForAdapter(browser, \"sqlite\", {\n pageErrors,\n recorder,\n sqliteDatabasePath: options.sqliteDatabasePath\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const parityFailures = getInformationPanelParityFailures(json, sqlite);\n if (parityFailures.length) {\n throw new Error(`Information panel JSON/SQLite parity failed: ${parityFailures.join(\" \")}`);\n }\n\n return {\n json,\n sqlite,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"information-parity-json-desktop\",\n \"information-parity-json-mobile\",\n \"information-parity-sqlite-desktop\",\n \"information-parity-sqlite-mobile\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --information-parity\n Run a focused JSON/SQLite browser smoke for the 局势簿 route-view parity surface.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = args.informationParity\n ? await runInformationPanelParitySmoke(args)\n : await runBrowserSmoke(args);\n if (args.informationParity) {\n console.log(`Browser information parity smoke passed: JSON ${result.json.baseUrl}, SQLite ${result.sqlite.baseUrl}`);\n console.log(`JSON session: ${result.json.sessionId}`);\n console.log(`SQLite session: ${result.sqlite.sessionId}`);\n } else {\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n }\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getInformationPanelParityFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeInformationPanelParitySnapshot,\n normalizeInformationRoutePayload,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveInformationParitySqlitePlan,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runInformationPanelParitySmoke,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "54505b3b800db255a9d1ff9161ca07655c21d623": {"t": 1785753949.1504827, "n": 126954, "h": "c506770f9d5780e0", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { createJsonSessionAdapter } = require(\"../src/storage/jsonSessionAdapter\");\nconst { createSqliteSessionAdapter } = require(\"../src/storage/sqliteSessionAdapter\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nlet activeSessionStore = createJsonSessionAdapter();\n\nfunction normalizeSmokeStorageAdapter(value) {\n const adapter = String(value || \"json\").trim().toLowerCase();\n if (adapter === \"json\" || adapter === \"sqlite\") return adapter;\n throw new Error(`Unsupported browser smoke storage adapter: ${value}`);\n}\n\nfunction createBrowserSmokeSessionStore(options = {}) {\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n if (storageAdapter === \"sqlite\") {\n return createSqliteSessionAdapter({\n databasePath: options.sqliteDatabasePath || process.env.SQLITE_DATABASE_PATH || process.env.SQLITE_DB_PATH\n });\n }\n return createJsonSessionAdapter();\n}\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n sqliteDatabasePath: null,\n storageAdapter: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else if (arg === \"--storage-adapter\") {\n args.storageAdapter = normalizeSmokeStorageAdapter(readArgValue(argv, index, \"--storage-adapter\"));\n index += 1;\n } else if (arg === \"--sqlite-db\" || arg === \"--sqlite-database\") {\n args.sqliteDatabasePath = readArgValue(argv, index, arg);\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer(options = {}) {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const storageAdapter = normalizeSmokeStorageAdapter(options.storageAdapter || process.env.STORAGE_ADAPTER || \"json\");\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n STORAGE_ADAPTER: storageAdapter,\n ...(storageAdapter === \"sqlite\" && options.sqliteDatabasePath\n ? { SQLITE_DATABASE_PATH: options.sqliteDatabasePath }\n : {}),\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(snapshot.eventArchiveSourceTypes, expectedEventArchiveSourceTypes);\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"event_log\",\n \"ai_change_proposals\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await activeSessionStore.readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await activeSessionStore.writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await activeSessionStore.readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await activeSessionStore.readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await activeSessionStore.readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await activeSessionStore.readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId, sessionStore = activeSessionStore) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n try {\n await sessionStore.deleteSession(sessionId);\n } catch (error) {\n if (error.statusCode !== 404) throw error;\n }\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n const previousSessionStore = activeSessionStore;\n const sessionStore = createBrowserSmokeSessionStore(options);\n activeSessionStore = sessionStore;\n\n try {\n server = baseUrl ? null : await startLocalServer(options);\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n let cleanupError = null;\n try {\n for (const id of idsToClean) {\n await cleanupSession(id, sessionStore);\n }\n } catch (error) {\n cleanupError = error;\n } finally {\n if (typeof sessionStore.close === \"function\") sessionStore.close();\n activeSessionStore = previousSessionStore;\n if (server) {\n await server.stop();\n }\n }\n if (cleanupError) {\n throw cleanupError;\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --storage-adapter <json|sqlite>\n Select storage for the smoke helper and auto-started server. Defaults to json.\n --sqlite-db <path>\n SQLite database path when --storage-adapter sqlite is used.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createBrowserSmokeSessionStore,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n normalizeSmokeStorageAdapter,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "80a0c074662d5ac8dfcfc9fce25f0b47608e01c9": {"t": 1785753952.0470552, "n": 124388, "h": "c91ab93457390213", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(snapshot.eventArchiveSourceTypes, expectedEventArchiveSourceTypes);\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"event_log\",\n \"ai_change_proposals\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"MIMO_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"MIMO_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "bac7d2faade7e94279095e1f86f498696f6ef1ed": {"t": 1785753954.3582125, "n": 124352, "h": "8449f35da7d206f8", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.eventArchiveGridClientWidth > 0 && metrics.eventArchiveGridScrollWidth > metrics.eventArchiveGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} event archive grid has horizontal scroll overflow (${roundMetric(metrics.eventArchiveGridScrollWidth)}px > ${roundMetric(metrics.eventArchiveGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const eventArchiveGrid = box(\"#event-archive-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n eventArchiveGridClientWidth: eventArchiveGrid?.clientWidth || 0,\n eventArchiveGridScrollWidth: eventArchiveGrid?.scrollWidth || 0,\n eventArchiveGridWidth: eventArchiveGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const defaultReadyPanels = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedReadyPanels = expectations.expectedReadyPanels || (\n expectations.expectEventArchiveReady === false\n ? defaultReadyPanels\n : [...defaultReadyPanels, \"event-archive-panel\"]\n );\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n const expectedEventArchiveSourceTypes = expectations.expectedEventArchiveSourceTypes || [\"event_history\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled === true && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== true && (snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel kept event archive disabled after sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (expectations.expectEventArchiveReady === false && Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady !== false) {\n const itemCount = Number(snapshot.eventArchiveItemCount ?? 0);\n if (itemCount < 1) {\n failures.push(`${mode} information panel did not render event archive items.`);\n }\n const missingEventSources = getMissingInformationPanelItems(snapshot.eventArchiveSourceTypes, expectedEventArchiveSourceTypes);\n if (missingEventSources.length) {\n failures.push(`${mode} information panel is missing event archive source types: ${missingEventSources.join(\", \")}.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveMetricCount) < itemCount * 2) {\n failures.push(`${mode} information panel has event archive items without enough visible metrics.`);\n }\n if (itemCount > 0 && Number(snapshot.eventArchiveStructuredCount) < itemCount) {\n failures.push(`${mode} information panel has event archive items without required data attributes.`);\n }\n }\n\n const defaultInformationHiddenTokens = [\n \"provider\",\n \"proposal\",\n \"prompt\",\n \"statePatch\",\n \"retrievalContext\",\n \"event_log\",\n \"ai_change_proposals\",\n \"data/audit\",\n \"data/sessions\",\n \"OPENAI_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"ANTHROPIC_API_KEY\"\n ];\n const hiddenTokens = [...new Set([...(expectations.hiddenTextTokens || []), ...defaultInformationHiddenTokens])];\n const hiddenLeaks = hiddenTokens.filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n eventArchiveSourceTypes: eventArchiveItems.map((item) => item.dataset.sourceType).filter(Boolean),\n eventArchiveStatuses: eventArchiveItems.map((item) => item.dataset.status).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n eventArchiveMetricCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item .information-card-metric\").length,\n eventArchiveStructuredCount: document.querySelectorAll(\"#event-archive-panel .event-archive-item[data-event-id][data-source-type][data-status][data-turn][data-year][data-month][data-ten-day-period]\").length,\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n const tabLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information ${tabId}`);\n if (tabLayoutFailures.length) {\n failUiAcceptance(tabLayoutFailures.join(\" \"));\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"]\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "e642ae36eebea14cc44b4e9247778b0302e1d8d6": {"t": 1785753956.160192, "n": 121311, "h": "8d1768de2fd9c915", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.worldPeopleGridClientWidth > 0 && metrics.worldPeopleGridScrollWidth > metrics.worldPeopleGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world people grid has horizontal scroll overflow (${roundMetric(metrics.worldPeopleGridScrollWidth)}px > ${roundMetric(metrics.worldPeopleGridClientWidth)}px).`\n );\n }\n\n if (metrics.officialPostingsGridClientWidth > 0 && metrics.officialPostingsGridScrollWidth > metrics.officialPostingsGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official postings grid has horizontal scroll overflow (${roundMetric(metrics.officialPostingsGridScrollWidth)}px > ${roundMetric(metrics.officialPostingsGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const worldPeopleGrid = box(\"#world-people-panel .information-detail-grid\");\n const officialPostingsGrid = box(\"#official-postings-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n worldPeopleGridClientWidth: worldPeopleGrid?.clientWidth || 0,\n worldPeopleGridScrollWidth: worldPeopleGrid?.scrollWidth || 0,\n worldPeopleGridWidth: worldPeopleGrid?.width || 0,\n officialPostingsGridClientWidth: officialPostingsGrid?.clientWidth || 0,\n officialPostingsGridScrollWidth: officialPostingsGrid?.scrollWidth || 0,\n officialPostingsGridWidth: officialPostingsGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const expectedReadyPanels = expectations.expectedReadyPanels || [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n const expectedWorldPeopleKinds = expectations.expectedWorldPeopleKinds || [\"npc\", \"relationship\"];\n const expectedOfficialPostingKinds = expectations.expectedOfficialPostingKinds || [\"bureau\", \"office\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== false && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectWorldPeopleDetails !== false) {\n const missingPeopleKinds = getMissingInformationPanelItems(snapshot.worldPeopleKinds, expectedWorldPeopleKinds);\n if (missingPeopleKinds.length) {\n failures.push(`${mode} information panel is missing world people card kinds: ${missingPeopleKinds.join(\", \")}.`);\n }\n if (Number(snapshot.worldPeopleCardCount) > 0 && Number(snapshot.worldPeopleMetricCount) < Number(snapshot.worldPeopleCardCount) * 2) {\n failures.push(`${mode} information panel has world people cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectOfficialPostingDetails !== false) {\n const missingOfficialKinds = getMissingInformationPanelItems(snapshot.officialPostingKinds, expectedOfficialPostingKinds);\n if (missingOfficialKinds.length) {\n failures.push(`${mode} information panel is missing official posting card kinds: ${missingOfficialKinds.join(\", \")}.`);\n }\n if (Number(snapshot.officialPostingCardCount) > 0 && Number(snapshot.officialPostingMetricCount) < Number(snapshot.officialPostingCardCount) * 2) {\n failures.push(`${mode} information panel has official posting cards without enough visible metrics.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (Number(snapshot.eventArchiveItemCount ?? snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered event archive items before sanitized projection.`);\n }\n\n const hiddenLeaks = (expectations.hiddenTextTokens || []).filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n const worldPeopleCards = [...document.querySelectorAll(\"#world-people-panel .world-people-card\")];\n const officialPostingCards = [...document.querySelectorAll(\"#official-postings-panel .official-posting-card\")];\n const eventArchiveItems = [...document.querySelectorAll(\"#event-archive-panel .event-archive-item\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n worldPeopleKinds: worldPeopleCards.map((card) => card.dataset.kind).filter(Boolean),\n officialPostingKinds: officialPostingCards.map((card) => card.dataset.kind).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n worldPeopleCardCount: worldPeopleCards.length,\n officialPostingCardCount: officialPostingCards.length,\n worldPeopleMetricCount: document.querySelectorAll(\"#world-people-panel .world-people-card .information-card-metric\").length,\n officialPostingMetricCount: document.querySelectorAll(\"#official-postings-panel .official-posting-card .information-card-metric\").length,\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n eventArchiveItemCount: eventArchiveItems.length,\n futureContentCardCount: eventArchiveItems.length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectedOfficialPostingKinds: [\"bureau\", \"office\", \"posting\", \"assessment\"],\n expectRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedOfficialPostingKinds: acceptanceCase.role === \"official\" || acceptanceCase.role === \"magistrate\"\n ? [\"bureau\", \"office\", \"posting\", \"assessment\"]\n : [\"bureau\", \"office\"],\n expectRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"hiddenIntent\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "657c08e04082811051e1b56f2294bc2bc5c885f6": {"t": 1785753957.7787302, "n": 117212, "h": "64ccc31c085973b3", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.informationPageClientWidth > 0 && metrics.informationPageScrollWidth > metrics.informationPageClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel active page has horizontal scroll overflow (${roundMetric(metrics.informationPageScrollWidth)}px > ${roundMetric(metrics.informationPageClientWidth)}px).`\n );\n }\n\n if (metrics.worldGeographyGridClientWidth > 0 && metrics.worldGeographyGridScrollWidth > metrics.worldGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world geography grid has horizontal scroll overflow (${roundMetric(metrics.worldGeographyGridScrollWidth)}px > ${roundMetric(metrics.worldGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.postingGeographyGridClientWidth > 0 && metrics.postingGeographyGridScrollWidth > metrics.postingGeographyGridClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} posting geography grid has horizontal scroll overflow (${roundMetric(metrics.postingGeographyGridScrollWidth)}px > ${roundMetric(metrics.postingGeographyGridClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const informationPage = box(\"#information-panel .information-panel-page:not([hidden])\");\n const worldGeographyGrid = box(\"#world-geography-panel .information-detail-grid\");\n const postingGeographyGrid = box(\"#posting-geography-panel .information-detail-grid\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n informationPageClientWidth: informationPage?.clientWidth || 0,\n informationPageScrollWidth: informationPage?.scrollWidth || 0,\n informationPageWidth: informationPage?.width || 0,\n worldGeographyGridClientWidth: worldGeographyGrid?.clientWidth || 0,\n worldGeographyGridScrollWidth: worldGeographyGrid?.scrollWidth || 0,\n worldGeographyGridWidth: worldGeographyGrid?.width || 0,\n postingGeographyGridClientWidth: postingGeographyGrid?.clientWidth || 0,\n postingGeographyGridScrollWidth: postingGeographyGrid?.scrollWidth || 0,\n postingGeographyGridWidth: postingGeographyGrid?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const expectedReadyPanels = expectations.expectedReadyPanels || [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n const expectedWorldGeographyKinds = expectations.expectedWorldGeographyKinds || [\n \"country\",\n \"city\",\n \"route\",\n \"frontier\",\n \"office-jurisdiction\"\n ];\n const expectedPostingGeographyKinds = expectations.expectedPostingGeographyKinds || [\"jurisdiction\", \"route\"];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== false && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (expectations.expectWorldGeographyDetails !== false) {\n const missingWorldKinds = getMissingInformationPanelItems(snapshot.worldGeographyKinds, expectedWorldGeographyKinds);\n if (missingWorldKinds.length) {\n failures.push(`${mode} information panel is missing world geography card kinds: ${missingWorldKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectPostingGeographyDetails !== false) {\n const missingPostingKinds = getMissingInformationPanelItems(snapshot.postingGeographyKinds, expectedPostingGeographyKinds);\n if (missingPostingKinds.length) {\n failures.push(`${mode} information panel is missing posting geography card kinds: ${missingPostingKinds.join(\", \")}.`);\n }\n }\n\n if (expectations.expectNoRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) > 0) {\n failures.push(`${mode} information panel leaked role-visible geography to a restricted role.`);\n }\n\n if (expectations.expectRoleVisibleGeography && Number(snapshot.roleVisibleGeographyCount) < 1) {\n failures.push(`${mode} information panel did not render expected role-visible geography.`);\n }\n\n if (Number(snapshot.futureContentCardCount) > 0) {\n failures.push(`${mode} information panel rendered future content cards before S53.5-S53.6.`);\n }\n\n const hiddenLeaks = (expectations.hiddenTextTokens || []).filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n const worldGeographyCards = [...document.querySelectorAll(\"#world-geography-panel .world-geography-card\")];\n const postingGeographyCards = [...document.querySelectorAll(\"#posting-geography-panel .posting-geography-card\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n worldGeographyKinds: worldGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n postingGeographyKinds: postingGeographyCards.map((card) => card.dataset.kind).filter(Boolean),\n roleVisibleGeographyCount: document.querySelectorAll(\n '#world-geography-panel .world-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"role_visible\"], #posting-geography-panel .posting-geography-card[data-visibility=\"office_visible\"]'\n ).length,\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n futureContentCardCount: document.querySelectorAll(\n \"#information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n const visiblePanelIds = await page.evaluate(() =>\n [...document.querySelectorAll(\"#information-panel .information-panel-page\")]\n .filter((panel) => !panel.hidden)\n .map((panel) => panel.id)\n );\n if (visiblePanelIds.length !== 1 || visiblePanelIds[0] !== panelId) {\n failUiAcceptance(`${mode} information panel did not hide inactive panels while activating ${tabId}.`);\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await assertInformationPanelShell(page, \"mobile post-palace official\", {\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectRoleVisibleGeography: true,\n hiddenTextTokens: [\"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectedPostingGeographyKinds: [\"posting\", \"jurisdiction\", \"route\"],\n expectRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectNoRoleVisibleGeography: true,\n expectEventArchiveReady: false\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "89e73c25682cdd1eb849245aa76e72f8894fce2d": {"t": 1785753960.3669057, "n": 111776, "h": "7d0c47519ae74ea3", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.informationPanelClientWidth > 0 && metrics.informationPanelScrollWidth > metrics.informationPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} information panel has horizontal scroll overflow (${roundMetric(metrics.informationPanelScrollWidth)}px > ${roundMetric(metrics.informationPanelClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const informationPanel = box(\"#information-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n informationPanelClientWidth: informationPanel?.clientWidth || 0,\n informationPanelScrollWidth: informationPanel?.scrollWidth || 0,\n informationPanelWidth: informationPanel?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nconst informationPanelTabs = [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\", \"event-archive\"];\nconst informationPanelIds = [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\",\n \"event-archive-panel\"\n];\n\nfunction getMissingInformationPanelItems(actual = [], expected = []) {\n const available = new Set(actual.filter(Boolean));\n return expected.filter((item) => !available.has(item));\n}\n\nfunction getInformationPanelShellFailures(snapshot = {}, expectations = {}, mode = \"information\") {\n const failures = [];\n const expectedTabs = expectations.expectedTabs || informationPanelTabs;\n const expectedPanels = expectations.expectedPanels || informationPanelIds;\n const expectedReadyPanels = expectations.expectedReadyPanels || [\n \"world-geography-panel\",\n \"posting-geography-panel\",\n \"world-people-panel\",\n \"official-postings-panel\"\n ];\n\n const missingTabs = getMissingInformationPanelItems(snapshot.tabIds, expectedTabs);\n if (missingTabs.length) {\n failures.push(`${mode} information panel is missing tabs: ${missingTabs.join(\", \")}.`);\n }\n\n const missingPanels = getMissingInformationPanelItems(snapshot.panelIds, expectedPanels);\n if (missingPanels.length) {\n failures.push(`${mode} information panel is missing panels: ${missingPanels.join(\", \")}.`);\n }\n\n if (!snapshot.activeTab || !expectedTabs.includes(snapshot.activeTab)) {\n failures.push(`${mode} information panel has invalid active tab: ${snapshot.activeTab || \"none\"}.`);\n }\n\n const missingReadyPanels = getMissingInformationPanelItems(snapshot.readyPanelIds, expectedReadyPanels);\n if (missingReadyPanels.length) {\n failures.push(`${mode} information panel has route views missing for panels: ${missingReadyPanels.join(\", \")}.`);\n }\n\n if (expectations.expectEventArchiveDisabled !== false && !(snapshot.disabledTabIds || []).includes(\"event-archive\")) {\n failures.push(`${mode} information panel did not keep event archive disabled before sanitized projection.`);\n }\n\n if (expectations.expectEventArchiveReady === false && (snapshot.readyPanelIds || []).includes(\"event-archive-panel\")) {\n failures.push(`${mode} information panel marked event archive ready before sanitized projection.`);\n }\n\n if (Number(snapshot.contentCardCount) > 0) {\n failures.push(`${mode} information panel rendered detailed content cards before S53.4-S53.6.`);\n }\n\n const hiddenLeaks = (expectations.hiddenTextTokens || []).filter((token) => token && String(snapshot.text || \"\").includes(token));\n if (hiddenLeaks.length) {\n failures.push(`${mode} information panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertInformationPanelShell(page, mode, expectations = {}) {\n await visibleBox(page, \"#information-panel[data-active-tab]\", `${mode} information panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#information-panel\");\n const pages = [...document.querySelectorAll(\"#information-panel .information-panel-page\")];\n const tabs = [...document.querySelectorAll(\"#information-panel .information-tab\")];\n return {\n activeTab: panel?.dataset.activeTab || \"\",\n tabIds: tabs.map((tab) => tab.dataset.tabId),\n disabledTabIds: tabs.filter((tab) => tab.disabled || tab.getAttribute(\"aria-disabled\") === \"true\").map((tab) => tab.dataset.tabId),\n panelIds: pages.map((page) => page.id),\n readyPanelIds: pages.filter((page) => page.dataset.viewReady === \"true\").map((page) => page.id),\n sourceViews: pages.map((page) => page.dataset.sourceView || \"\"),\n visiblePanelIds: pages.filter((page) => !page.hidden).map((page) => page.id),\n contentCardCount: document.querySelectorAll(\n \"#information-panel .world-geography-card, #information-panel .posting-geography-card, #information-panel .world-people-card, #information-panel .official-posting-card, #information-panel .event-archive-item\"\n ).length,\n text: panel?.textContent || \"\"\n };\n });\n\n const failures = getInformationPanelShellFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const enabledTabs = (expectations.expectedSwitchTabs || [\"world-geography\", \"posting-geography\", \"world-people\", \"official-postings\"]);\n for (const tabId of enabledTabs) {\n await page.locator(`#information-panel .information-tab[data-tab-id=\"${tabId}\"]`).click();\n const panelId = `${tabId}-panel`;\n await page.locator(`#${panelId}`).waitFor({ state: \"visible\", timeout: 5000 });\n const activeTab = await page.locator(\"#information-panel\").getAttribute(\"data-active-tab\");\n if (activeTab !== tabId) {\n failUiAcceptance(`${mode} information panel did not activate tab ${tabId}.`);\n }\n }\n await page.locator('#information-panel .information-tab[data-tab-id=\"world-geography\"]').click();\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} information`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"mobile scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertInformationPanelShell(page, \"official assignment\", {\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n await assertInformationPanelShell(page, `${acceptanceCase.role} role-world`, {\n hiddenTextTokens: [\"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop scholar after turn\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(page, \"desktop restored scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await assertInformationPanelShell(freshPage, \"fresh page desktop scholar\", {\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\", \"OPENAI_API_KEY\", \"data/sessions\"],\n expectEventArchiveReady: false\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getInformationPanelShellFailures,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "6bcfb7725c7d891e29d96bfa2d390cbc785884af": {"t": 1785753964.0871952, "n": 104500, "h": "2f2ccc6b7b228562", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction hasTenDayPeriodLabel(text = \"\") {\n return /(?:上旬|中旬|下旬)/.test(String(text || \"\"));\n}\n\nfunction getTenDayDateFailures(entries = {}, mode = \"fixture\") {\n return Object.entries(entries)\n .filter(([, text]) => !hasTenDayPeriodLabel(text))\n .map(([label]) => `${mode} ${label} is missing a ten-day date label.`);\n}\n\nfunction assertTenDayDateText(text, label) {\n const failures = getTenDayDateFailures({ [label]: text }, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n assertTenDayDateText(snapshot.text, `${mode} exam calendar`);\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#narrative .world-tick\").last().waitFor({ state: \"visible\", timeout: 10000 });\n const worldTickText = await page.locator(\"#narrative .world-tick\").last().innerText();\n assertTenDayDateText(worldTickText, \"relationship turn world tick\");\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n assertTenDayDateText(text, `${mode} save list`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display,\n statusText: document.querySelector(\"#status-strip\")?.innerText || \"\"\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n assertTenDayDateText(computed.statusText, `${mode} status strip`);\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\"),\n modalText: document.querySelector(\".exam-modal\")?.innerText || \"\",\n status\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n const dateFailures = getTenDayDateFailures({\n \"exam modal\": sceneState.modalText,\n \"exam scene status\": sceneState.status\n }, mode);\n if (dateFailures.length) {\n failUiAcceptance(dateFailures.join(\" \"));\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultText: resultElement?.innerText || \"\",\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n assertTenDayDateText(counts.resultText, `${mode} exam result`);\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.tenDayPeriod = 3;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getTenDayDateFailures,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n hasTenDayPeriodLabel,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "54afc384cb81f2cddae7a904d220f71ab9cf2cb9": {"t": 1785753965.943698, "n": 102960, "h": "470a9fa7e7d3335a", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const sceneTools = await visibleBox(page, \"#exam-scene-tools\", `${mode} exam scene tools`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, sceneTools)) {\n failUiAcceptance(`${mode} exam requirements overlap the scene tools.`);\n }\n if (rectsOverlap(sceneTools, tools)) {\n failUiAcceptance(`${mode} exam scene tools overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n\n const sceneState = await page.evaluate(() => {\n const status = document.querySelector(\"#exam-scene-status\")?.textContent || \"\";\n return {\n actionCount: document.querySelectorAll(\"[data-exam-action]\").length,\n hasStatus: status.includes(\"场内阶段\")\n };\n });\n if (!sceneState.hasStatus || sceneState.actionCount < 4) {\n failUiAcceptance(`${mode} exam modal is missing scene phase controls.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n const draftProbe = `草稿留存-${expectations.level}`;\n await page.locator(\"#exam-essay\").fill(draftProbe);\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level} reopened`);\n const reopenedDraft = await page.locator(\"#exam-essay\").inputValue();\n if (reopenedDraft !== draftProbe) {\n failUiAcceptance(`desktop ${expectations.level} did not preserve the in-progress exam draft.`);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "dc7bea8adee20ec3b03ef75cc50f88062919afa4": {"t": 1785753968.5088887, "n": 101636, "h": "fb021092dfaa80f8", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n checkAiConnection: false,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--check-ai-connection\") {\n args.checkAiConnection = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getAiConnectionPanelFailures(panel = {}, options = {}, label = \"AI connection\") {\n const failures = [];\n const expectedProvider = options.expectedProvider ? String(options.expectedProvider).toLowerCase() : \"\";\n const hiddenTextTokens = options.hiddenTextTokens || [];\n const statusText = String(panel.statusText || \"\");\n const resultText = String(panel.resultText || \"\");\n const combinedText = `${statusText}\\n${resultText}`;\n\n if (panel.resultOk !== \"true\") {\n failures.push(`${label} did not report a passing result.`);\n }\n if (expectedProvider && !combinedText.toLowerCase().includes(expectedProvider)) {\n failures.push(`${label} did not mention expected provider ${expectedProvider}.`);\n }\n if (!/当前配置/.test(resultText)) {\n failures.push(`${label} did not render the configured provider line.`);\n }\n if (!/default\\s*:/.test(resultText)) {\n failures.push(`${label} did not render a default model summary.`);\n }\n if (panel.beforeSessionId !== panel.afterSessionId) {\n failures.push(`${label} changed qianqiu.sessionId during a no-session diagnostic.`);\n }\n if (panel.actionAreaVisible) {\n failures.push(`${label} unexpectedly entered the game action area.`);\n }\n\n const leakedTokens = hiddenTextTokens.filter((token) => token && combinedText.includes(token));\n if (leakedTokens.length) {\n failures.push(`${label} leaked hidden text tokens: ${leakedTokens.join(\", \")}.`);\n }\n\n return failures;\n}\n\nasync function assertAiConnectionPanel(page, label, options = {}) {\n await page.locator(\"#ai-connection-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const beforeSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\") || \"\");\n\n await page.locator(\"#ai-test-button\").click();\n await page.waitForFunction(() => {\n const result = document.querySelector(\"#ai-test-result\");\n const button = document.querySelector(\"#ai-test-button\");\n return result?.dataset.ok && button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const summary = await page.evaluate((before) => {\n const isVisible = (selector) => {\n const element = document.querySelector(selector);\n if (!element || element.hidden) return false;\n const style = window.getComputedStyle(element);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n };\n\n return {\n beforeSessionId: before,\n afterSessionId: window.localStorage.getItem(\"qianqiu.sessionId\") || \"\",\n actionAreaVisible: isVisible(\"#action-area\"),\n resultOk: document.querySelector(\"#ai-test-result\")?.dataset.ok || \"\",\n resultText: document.querySelector(\"#ai-test-result\")?.innerText || \"\",\n statusText: document.querySelector(\"#ai-test-status\")?.innerText || \"\"\n };\n }, beforeSessionId);\n\n const failures = getAiConnectionPanelFailures(summary, options, label);\n if (failures.length) {\n failUiAcceptance(failures.join(\"\\n\"));\n }\n\n return {\n ok: summary.resultOk === \"true\",\n statusText: summary.statusText.replace(/\\s+/g, \" \").trim(),\n resultText: summary.resultText.replace(/\\s+/g, \" \").trim()\n };\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n checkAiConnection = false,\n expectedAiConnectionProvider = null,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n const aiConnection = checkAiConnection\n ? await assertAiConnectionPanel(page, \"start-page AI connection\", {\n expectedProvider: expectedAiConnectionProvider,\n hiddenTextTokens: [\"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_API_KEY\", \"data/sessions\"]\n })\n : null;\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n const acceptanceAreas = [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ];\n if (aiConnection) acceptanceAreas.unshift(\"ai-connection\");\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n aiConnection,\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: acceptanceAreas\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n checkAiConnection: Boolean(options.checkAiConnection),\n expectedAiConnectionProvider: baseUrl ? null : \"mock\",\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --check-ai-connection\n Click the start-page AI connection diagnostic. The auto-started server uses Mock;\n --url targets should enable this only when the target provider is intentionally checkable.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getAiConnectionPanelFailures,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "5c8310a14a5ab7e3e2b20a78e7157754510deddb": {"t": 1785753970.5979762, "n": 97548, "h": "a1e689f4c80f9f2c", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n expectedThreadKind: \"local_case\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n expectedThreadKind: \"border\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n expectedThreadKind: \"faction_conflict\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.worldThreadClientWidth > 0 && metrics.worldThreadScrollWidth > metrics.worldThreadClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} world thread panel has horizontal scroll overflow (${roundMetric(metrics.worldThreadScrollWidth)}px > ${roundMetric(metrics.worldThreadClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const worldThread = box(\"#world-thread-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n worldThreadClientWidth: worldThread?.clientWidth || 0,\n worldThreadScrollWidth: worldThread?.scrollWidth || 0,\n worldThreadWidth: worldThread?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingWorldThreadKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingWorldThreadSourceTypes(actualSourceTypes = [], expectedSourceTypes = []) {\n const available = new Set(actualSourceTypes.filter(Boolean));\n return expectedSourceTypes.filter((sourceType) => !available.has(sourceType));\n}\n\nfunction getHiddenWorldThreadTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getWorldThreadPanelFailures(snapshot = {}, expectations = {}, mode = \"world thread\") {\n const failures = [];\n const cards = Number(snapshot.cardCount) || 0;\n\n if (expectations.expectActive && cards <= 0) {\n failures.push(`${mode} world thread panel did not render any active thread cards.`);\n }\n\n const missingKinds = getMissingWorldThreadKinds(snapshot.kinds, expectations.expectedKinds || []);\n if (missingKinds.length) {\n failures.push(`${mode} world thread panel is missing thread kinds: ${missingKinds.join(\", \")}.`);\n }\n\n const missingSourceTypes = getMissingWorldThreadSourceTypes(snapshot.sourceTypes, expectations.expectedSourceTypes || []);\n if (missingSourceTypes.length) {\n failures.push(`${mode} world thread panel is missing source types: ${missingSourceTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedStatuses?.length) {\n const availableStatuses = new Set(snapshot.statuses || []);\n const hasExpectedStatus = expectations.expectedStatuses.some((status) => availableStatuses.has(status));\n if (!hasExpectedStatus) {\n failures.push(`${mode} world thread panel is missing allowed statuses: ${expectations.expectedStatuses.join(\", \")}.`);\n }\n }\n\n if (cards > 0) {\n const fieldChecks = [\n [\"goals\", snapshot.goalCount],\n [\"deadlines\", snapshot.deadlineCount],\n [\"risk labels\", snapshot.riskCount],\n [\"related labels\", snapshot.relatedCount],\n [\"intervention hints\", snapshot.hintCount],\n [\"follow-up hints\", snapshot.followUpCount]\n ];\n for (const [label, count] of fieldChecks) {\n if (count < cards) {\n failures.push(`${mode} world thread panel has ${count} ${label} for ${cards} thread cards.`);\n }\n }\n const missingRiskData = (snapshot.risks || []).filter((risk) => !risk).length;\n if (missingRiskData) {\n failures.push(`${mode} world thread panel has ${missingRiskData} thread cards without data-risk.`);\n }\n }\n\n const hiddenLeaks = getHiddenWorldThreadTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} world thread panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertWorldThreadPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#world-thread-panel[data-generated-turn][data-active-count][data-watch-count]\", `${mode} world thread panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#world-thread-panel\");\n const cards = [\n ...document.querySelectorAll(\n \"#world-thread-panel .world-thread-card[data-thread-id][data-source-type][data-thread-kind][data-status][data-severity][data-risk]\"\n )\n ];\n return {\n activeCount: panel?.dataset.activeCount || \"\",\n generatedTurn: panel?.dataset.generatedTurn || \"\",\n watchCount: panel?.dataset.watchCount || \"\",\n cardCount: cards.length,\n ids: cards.map((card) => card.dataset.threadId),\n sourceTypes: cards.map((card) => card.dataset.sourceType),\n kinds: cards.map((card) => card.dataset.threadKind),\n statuses: cards.map((card) => card.dataset.status),\n severities: cards.map((card) => card.dataset.severity),\n risks: cards.map((card) => card.dataset.risk),\n goalCount: document.querySelectorAll(\"#world-thread-panel .world-thread-goal\").length,\n deadlineCount: document.querySelectorAll(\"#world-thread-panel .world-thread-deadline\").length,\n riskCount: document.querySelectorAll(\"#world-thread-panel .world-thread-risk\").length,\n relatedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-related\").length,\n hintCount: document.querySelectorAll(\"#world-thread-panel .world-thread-hint\").length,\n followUpCount: document.querySelectorAll(\"#world-thread-panel .world-thread-followup\").length,\n resolvedCount: document.querySelectorAll(\"#world-thread-panel .world-thread-resolved-item\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getWorldThreadPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} world thread`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"mobile scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n await assertWorldThreadPanel(page, \"official assignment\", {\n expectActive: true,\n expectedKinds: [\"official_assignment\"],\n expectedSourceTypes: [\"official_assignment\"],\n expectedStatuses: [\"active\", \"watch\"],\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n await assertWorldThreadPanel(page, `${acceptanceCase.role} role-world`, {\n expectActive: true,\n expectedKinds: [acceptanceCase.expectedThreadKind],\n expectedSourceTypes: [\"role_world_coupling\"]\n });\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop scholar after turn\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n expectedStatuses: [\"active\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(page, \"desktop restored scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertWorldThreadPanel(freshPage, \"fresh page desktop scholar\", {\n expectActive: true,\n expectedKinds: [\"npc_request\"],\n expectedSourceTypes: [\"active_npc_request\"],\n hiddenTextTokens: [\"Hidden Palace Thread\", \"sealed palace dossier\", \"C99-hidden\", \"hiddenNotes\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"world-thread\",\n \"role-world\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenWorldThreadTextLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n getMissingWorldThreadKinds,\n getMissingWorldThreadSourceTypes,\n getWorldThreadPanelFailures,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "60b2fd73e6008c039d7926514db437b700c1788f": {"t": 1785753972.8438835, "n": 89929, "h": "7ffb8e3eb50ad815", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingOfficialCareerAssignmentKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds.filter(Boolean));\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingOfficialCareerAssignmentStatuses(actualStatuses = [], expectedStatuses = []) {\n const available = new Set(actualStatuses.filter(Boolean));\n if (!expectedStatuses.length) return [];\n return expectedStatuses.some((status) => available.has(status)) ? [] : expectedStatuses;\n}\n\nfunction getHiddenOfficialCareerTextLeaks(text = \"\", hiddenTextTokens = []) {\n return hiddenTextTokens.filter((token) => token && String(text).includes(token));\n}\n\nfunction getOfficialCareerAssignmentRecords(snapshot = {}) {\n if (Array.isArray(snapshot.assignmentRecords)) {\n return snapshot.assignmentRecords;\n }\n return (snapshot.assignmentIds || []).map((id, index) => ({\n id,\n kind: snapshot.assignmentKinds?.[index] || \"\",\n status: snapshot.assignmentStatuses?.[index] || \"\",\n bureauId: snapshot.assignmentBureauIds?.[index] || \"\"\n }));\n}\n\nfunction getOfficialCareerPanelFailures(snapshot = {}, expectations = {}, mode = \"official\") {\n const failures = [];\n\n if (expectations.expectOutcome && !snapshot.outcomeIds?.length) {\n failures.push(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failures.push(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !String(snapshot.currentPosting || \"\").includes(expectations.expectedPosting)) {\n failures.push(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (expectations.expectedBureauId && !snapshot.bureauIds?.includes(expectations.expectedBureauId)) {\n failures.push(`${mode} official career panel is missing bureau id: ${expectations.expectedBureauId}.`);\n }\n if (snapshot.bureauIds?.length && snapshot.bureauDutyCount < snapshot.bureauIds.length) {\n failures.push(`${mode} official career panel has incomplete bureau duty fields.`);\n }\n\n if (expectations.expectAssignment && !snapshot.assignmentIds?.length) {\n failures.push(`${mode} official career panel did not render any assignment rows.`);\n }\n if (snapshot.assignmentIds?.length && !snapshot.assignmentSummaryCount) {\n failures.push(`${mode} official career panel did not render an assignment summary.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentProgressCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment progress fields.`);\n }\n if (snapshot.assignmentIds?.length && snapshot.assignmentRiskCount < snapshot.assignmentIds.length) {\n failures.push(`${mode} official career panel has incomplete assignment risk fields.`);\n }\n\n const missingAssignmentKinds = getMissingOfficialCareerAssignmentKinds(\n snapshot.assignmentKinds,\n expectations.expectedAssignmentKinds || []\n );\n if (missingAssignmentKinds.length) {\n failures.push(`${mode} official career panel is missing assignment kinds: ${missingAssignmentKinds.join(\", \")}.`);\n }\n\n const expectedAssignmentStatuses = expectations.expectedAssignmentStatuses || [];\n if (expectedAssignmentStatuses.length && (expectations.expectedAssignmentKinds || []).length) {\n const records = getOfficialCareerAssignmentRecords(snapshot);\n (expectations.expectedAssignmentKinds || []).forEach((kind) => {\n const matchingRecords = records.filter((record) => record.kind === kind);\n if (matchingRecords.length && !matchingRecords.some((record) => expectedAssignmentStatuses.includes(record.status))) {\n failures.push(`${mode} official career panel has no ${kind} assignment with allowed statuses: ${expectedAssignmentStatuses.join(\", \")}.`);\n }\n });\n } else {\n const missingAssignmentStatuses = getMissingOfficialCareerAssignmentStatuses(\n snapshot.assignmentStatuses,\n expectedAssignmentStatuses\n );\n if (missingAssignmentStatuses.length) {\n failures.push(`${mode} official career panel is missing assignment statuses: ${missingAssignmentStatuses.join(\", \")}.`);\n }\n }\n\n if (expectations.expectAssessment && !snapshot.assessments?.length) {\n failures.push(`${mode} official career panel did not render an assessment block.`);\n }\n if (expectations.expectAssessment && !snapshot.assessmentViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server assessment view data.`);\n }\n if (snapshot.assessments?.length && snapshot.assessmentNoteCount < snapshot.assessments.length) {\n failures.push(`${mode} official career panel has incomplete assessment notes.`);\n }\n\n if (expectations.expectNetwork && !snapshot.networkCount) {\n failures.push(`${mode} official career panel did not render a network block.`);\n }\n if (expectations.expectNetwork && !snapshot.networkViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server network view data.`);\n }\n\n if (expectations.expectedImpeachmentStage) {\n if (snapshot.panelImpeachmentStage !== expectations.expectedImpeachmentStage) {\n failures.push(`${mode} official career panel data stage is not ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureStages?.includes(expectations.expectedImpeachmentStage)) {\n failures.push(`${mode} official career panel is missing impeachment stage: ${expectations.expectedImpeachmentStage}.`);\n }\n if (!snapshot.procedureViewReady?.includes(\"true\")) {\n failures.push(`${mode} official career panel did not render server procedure view data.`);\n }\n }\n\n const hiddenLeaks = getHiddenOfficialCareerTextLeaks(snapshot.text, expectations.hiddenTextTokens || []);\n if (hiddenLeaks.length) {\n failures.push(`${mode} official career panel leaked hidden text tokens: ${hiddenLeaks.join(\", \")}.`);\n }\n\n if (\n snapshot.outcomeIds?.length &&\n (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)\n ) {\n failures.push(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n return failures;\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(\n page,\n \"#official-career-panel[data-current-posting][data-pending-review][data-impeachment-stage]\",\n `${mode} official career panel`\n );\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n const bureaus = [...document.querySelectorAll(\"#official-career-panel .official-career-bureau[data-bureau-id]\")];\n const assignments = [\n ...document.querySelectorAll(\n \"#official-career-panel .official-career-assignment[data-assignment-id][data-assignment-kind][data-assignment-status][data-bureau-id]\"\n )\n ];\n const assessments = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-assessment[data-pending-recommendation]\")\n ];\n const procedures = [\n ...document.querySelectorAll(\"#official-career-panel .official-career-procedure[data-impeachment-stage]\")\n ];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n panelImpeachmentStage: panel?.dataset.impeachmentStage || \"\",\n bureauIds: bureaus.map((bureau) => bureau.dataset.bureauId),\n bureauDutyCount: document.querySelectorAll(\"#official-career-panel .official-career-bureau-duty\").length,\n assignmentSummaryCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-summary\").length,\n assignmentIds: assignments.map((assignment) => assignment.dataset.assignmentId),\n assignmentKinds: assignments.map((assignment) => assignment.dataset.assignmentKind),\n assignmentStatuses: assignments.map((assignment) => assignment.dataset.assignmentStatus),\n assignmentBureauIds: assignments.map((assignment) => assignment.dataset.bureauId),\n assignmentRecords: assignments.map((assignment) => ({\n id: assignment.dataset.assignmentId,\n kind: assignment.dataset.assignmentKind,\n status: assignment.dataset.assignmentStatus,\n bureauId: assignment.dataset.bureauId\n })),\n assignmentProgressCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-progress\").length,\n assignmentRiskCount: document.querySelectorAll(\"#official-career-panel .official-career-assignment-risk\").length,\n assessments: assessments.map((assessment) => assessment.dataset.pendingRecommendation),\n assessmentViewReady: assessments.map((assessment) => assessment.dataset.viewReady),\n assessmentNoteCount: document.querySelectorAll(\"#official-career-panel .official-career-assessment-note\").length,\n networkCount: document.querySelectorAll(\"#official-career-panel .official-career-network\").length,\n networkViewReady: [...document.querySelectorAll(\"#official-career-panel .official-career-network\")].map((network) => network.dataset.viewReady),\n procedureStages: procedures.map((procedure) => procedure.dataset.impeachmentStage),\n procedureViewReady: procedures.map((procedure) => procedure.dataset.viewReady),\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n const failures = getOfficialCareerPanelFailures(snapshot, expectations, mode);\n if (failures.length) {\n failUiAcceptance(failures.join(\" \"));\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle,\n expectedBureauId: worldState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: worldState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle,\n expectedBureauId: finalState.officialCareer?.bureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle,\n bureauId: finalState.officialCareer?.bureauId,\n impeachmentStage: finalState.officialCareer?.impeachmentProcedure?.stage || \"none\"\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting,\n expectedBureauId: expectations.expectedBureauId,\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: expectations.expectedImpeachmentStage || \"none\"\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\",\n expectedBureauId: \"ministry_personnel\",\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n await page.locator(\"#action-input\").fill(\"督办赈灾与赈银核销\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.waitForFunction(\n () => document.body.innerText.includes(\"[官场差遣]\"),\n null,\n { timeout: 10000 }\n );\n await assertOfficialCareerPanel(page, \"official assignment\", {\n expectedBureauId: \"ministry_personnel\",\n expectAssignment: true,\n expectedAssignmentKinds: [\"relief\"],\n expectedAssignmentStatuses: [\"active\", \"submitted\"],\n expectAssessment: true,\n expectNetwork: true,\n expectedImpeachmentStage: \"none\",\n hiddenTextTokens: [\"hiddenNotes\", \"有人暗中遮掩亏空\", \"密札指向上官\"]\n });\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle,\n expectedBureauId: examProgression.bureauId,\n expectedImpeachmentStage: examProgression.impeachmentStage\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"role-world\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenOfficialCareerTextLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerAssignmentKinds,\n getMissingOfficialCareerAssignmentStatuses,\n getMissingOfficialCareerOutcomeTypes,\n getOfficialCareerPanelFailures,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "b3442173da156f29e33fe34123733ef2bfb1046a": {"t": 1785753974.8108053, "n": 79457, "h": "963ef780443c4651", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertFailedSseRollback(page) {\n const leakedText = \"未入史流文\";\n const errorText = \"stream schema failed\";\n\n await page.evaluate(({ leakedText: pendingText, errorText: failureText }) => {\n const originalFetch = window.fetch.bind(window);\n window.__qianqiuRestoreFetch = () => {\n window.fetch = originalFetch;\n delete window.__qianqiuRestoreFetch;\n };\n window.fetch = (input, init) => {\n const url = typeof input === \"string\" ? input : input?.url || \"\";\n if (url.includes(\"/api/game/turn\")) {\n const encoder = new TextEncoder();\n const stream = new ReadableStream({\n start(controller) {\n controller.enqueue(encoder.encode(`event: narrative_chunk\\ndata: ${JSON.stringify({ text: pendingText })}\\n\\n`));\n controller.enqueue(encoder.encode(`event: error\\ndata: ${JSON.stringify({ error: failureText })}\\n\\n`));\n controller.close();\n }\n });\n return Promise.resolve(new Response(stream, {\n status: 200,\n headers: { \"content-type\": \"text/event-stream\" }\n }));\n }\n return originalFetch(input, init);\n };\n }, { leakedText, errorText });\n\n try {\n await page.locator(\"#action-input\").fill(\"触发失败流式回合\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n\n const narrativeText = await page.locator(\"#narrative\").innerText();\n if (narrativeText.includes(leakedText)) {\n failUiAcceptance(\"failed SSE narrative kept uncommitted streamed text in the browser history.\");\n }\n if (!narrativeText.includes(errorText)) {\n failUiAcceptance(\"failed SSE rollback did not render the stream error message.\");\n }\n } finally {\n await page.evaluate(() => {\n if (typeof window.__qianqiuRestoreFetch === \"function\") {\n window.__qianqiuRestoreFetch();\n }\n });\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertFailedSseRollback(page);\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"role-world\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "752bab503071a6ccdd254b7943d206df0e39e0f9": {"t": 1785753976.2935028, "n": 77394, "h": "3f24eac592d6e526", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.saveListPanelClientWidth > 0 && metrics.saveListPanelScrollWidth > metrics.saveListPanelClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list panel has horizontal scroll overflow (${roundMetric(metrics.saveListPanelScrollWidth)}px > ${roundMetric(metrics.saveListPanelClientWidth)}px).`\n );\n }\n\n if (metrics.saveListModalClientWidth > 0 && metrics.saveListModalScrollWidth > metrics.saveListModalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} save-list modal has horizontal scroll overflow (${roundMetric(metrics.saveListModalScrollWidth)}px > ${roundMetric(metrics.saveListModalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n const saveListPanel = box(\"#save-list-panel\");\n const saveListModal = box(\"#save-list-modal\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n saveListPanelClientWidth: saveListPanel?.clientWidth || 0,\n saveListPanelScrollWidth: saveListPanel?.scrollWidth || 0,\n saveListPanelWidth: saveListPanel?.width || 0,\n saveListModalClientWidth: saveListModal?.clientWidth || 0,\n saveListModalScrollWidth: saveListModal?.scrollWidth || 0,\n saveListModalWidth: saveListModal?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingSaveIds(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenSaveIdLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertSaveList(page, mode, { expectedIds = [], hiddenIds = [], modal = false } = {}) {\n const scope = modal ? \"#save-list-modal\" : \"#save-list-panel\";\n await page.locator(scope).waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(`${scope} .save-card`).first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const actualIds = await page.locator(`${scope} .save-card`).evaluateAll((cards) =>\n cards.map((card) => card.dataset.saveId).filter(Boolean)\n );\n const missingIds = getMissingSaveIds(actualIds, expectedIds);\n if (missingIds.length) {\n failUiAcceptance(`${mode} save list missing save ids: ${missingIds.join(\", \")}.`);\n }\n const hiddenLeaks = getHiddenSaveIdLeaks(actualIds, hiddenIds);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} save list leaked hidden save ids: ${hiddenLeaks.join(\", \")}.`);\n }\n\n const text = await page.locator(scope).innerText();\n [\"worldState\", \"relationshipLedger\", \"hiddenContacts\", \"provider\", \"prompt\"].forEach((token) => {\n if (text.includes(token)) {\n failUiAcceptance(`${mode} save list leaked raw storage detail: ${token}.`);\n }\n });\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n const saveFailures = gameLayoutFailures.filter((failure) => /save-list/.test(failure));\n if (saveFailures.length) {\n failUiAcceptance(saveFailures.join(\" \"));\n }\n}\n\nasync function assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertSaveList(page, \"desktop start page\", { expectedIds: [sessionId] });\n await page.locator(`#save-list-panel .save-card[data-save-id=\"${sessionId}\"] button`).click();\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const loadedId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (loadedId !== sessionId) {\n failUiAcceptance(`start-page save load wrote ${loadedId || \"no\"} localStorage id instead of ${sessionId}.`);\n }\n await assertGameLayout(page, \"desktop start-page save load\");\n } finally {\n await context.close();\n }\n}\n\nasync function assertGameSaveModal(page, sessionId) {\n await page.locator(\"#save-list-open\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertSaveList(page, \"desktop game save modal\", { expectedIds: [sessionId], modal: true });\n await page.locator(\"#save-close\").click();\n await page.locator(\"#save-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertGameSaveModal(page, sessionId);\n await assertStartPageSaveLoad(browser, { baseUrl, sessionId, pageErrors });\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"role-world\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenSaveIdLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingSaveIds,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "76745b3ae3b04547f18a74415987ad3c0f2952b3": {"t": 1785753977.9557333, "n": 72854, "h": "719e4095b2235063", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst examOpenMonthByLevel = Object.freeze({\n child_exam: 1,\n provincial_exam: 8,\n metropolitan_exam: 2,\n palace_exam: 4\n});\nconst examProgressionCases = Object.freeze([\n {\n level: \"child_exam\",\n expectedCompletedLevels: [\"child_exam\"],\n expectedRank: \"秀才\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"provincial_exam\",\n modalScreenshotName: \"desktop-exam-modal\",\n resultScreenshotName: \"desktop-child-exam-result\"\n },\n {\n level: \"provincial_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\"],\n expectedRank: \"举人\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"metropolitan_exam\",\n resultScreenshotName: \"desktop-provincial-exam-result\"\n },\n {\n level: \"metropolitan_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\"],\n expectedRank: \"贡士\",\n expectedRole: \"scholar\",\n expectedNextLevel: \"palace_exam\",\n resultScreenshotName: \"desktop-metropolitan-exam-result\"\n },\n {\n level: \"palace_exam\",\n expectedCompletedLevels: [\"child_exam\", \"provincial_exam\", \"metropolitan_exam\", \"palace_exam\"],\n expectedRank: \"进士\",\n expectedRole: \"official\",\n expectedOffice: true,\n resultScreenshotName: \"desktop-palace-exam-result\"\n }\n]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nfunction getMissingExamLevels(actualLevels = [], expectedLevels = []) {\n const available = new Set(actualLevels);\n return expectedLevels.filter((level) => !available.has(level));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const isMobileMode = String(mode).startsWith(\"mobile\");\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, isMobileMode ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (isMobileMode) {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay(level = \"child_exam\") {\n const repeatCount = {\n child_exam: 2,\n provincial_exam: 5,\n metropolitan_exam: 8,\n palace_exam: 7\n }[level] || 5;\n const paragraph = [\n \"夫治民者,当以仁义为本,以礼法为纲。\",\n \"臣闻民为邦本,本固则邦宁。\",\n \"故一曰宽赋以养民力,二曰明经以正士风,三曰择吏以清县政。\",\n \"盖仓廪足而礼义兴,学校修而风俗厚。\",\n \"是以为学不徒章句,必施于乡里;为政不尚苛急,必本于爱民。\",\n \"谨按古今治道,皆贵循序渐进,先修身而后齐家,先教化而后刑罚。\",\n \"由是观之,士子临文,宜明道义,陈利弊,守中正,不为浮辞。\"\n ].join(\"\");\n return Array.from({ length: repeatCount }, () => paragraph).join(\"\");\n}\n\nfunction buildBrowserCheatingEssay() {\n return \"学而时习之不亦说乎。学而时习之不亦说乎。\";\n}\n\nasync function prepareSessionForExam(sessionId, level) {\n const worldState = await readSession(sessionId);\n if (worldState.activeExam) {\n failUiAcceptance(`session ${sessionId} already has an active exam before opening ${level}.`);\n }\n\n worldState.month = examOpenMonthByLevel[level] || 1;\n worldState.player.gold = Math.max(worldState.player.gold || 0, 1000);\n worldState.player.health = 100;\n worldState.player.teacher = worldState.player.teacher || \"顾文衡\";\n worldState.player.academia = Math.max(worldState.player.academia || 0, 100);\n worldState.player.literaryTalent = Math.max(worldState.player.literaryTalent || 0, 100);\n worldState.player.adaptability = Math.max(worldState.player.adaptability || 0, 100);\n worldState.player.mentality = Math.max(worldState.player.mentality || 0, 100);\n worldState.player.reputation = Math.max(worldState.player.reputation || 0, 100);\n await writeSession(worldState);\n return worldState;\n}\n\nfunction assertLatestExamProgression(worldState, expectations, mode) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n const latest = history.at(-1);\n if (!latest || latest.level !== expectations.level) {\n failUiAcceptance(`${mode} latest exam history is not ${expectations.level}.`);\n }\n if (!latest.promotionResult?.passed) {\n failUiAcceptance(`${mode} did not pass ${expectations.level}.`);\n }\n\n const completedLevels = history.map((entry) => entry.level);\n const missingLevels = getMissingExamLevels(completedLevels, expectations.expectedCompletedLevels || []);\n if (missingLevels.length) {\n failUiAcceptance(`${mode} exam history is missing completed levels: ${missingLevels.join(\", \")}.`);\n }\n if (worldState.player.examRank !== expectations.expectedRank) {\n failUiAcceptance(`${mode} expected exam rank ${expectations.expectedRank}, got ${worldState.player.examRank}.`);\n }\n if (worldState.player.role !== expectations.expectedRole) {\n failUiAcceptance(`${mode} expected role ${expectations.expectedRole}, got ${worldState.player.role}.`);\n }\n if (expectations.expectedOffice && !worldState.player.officeTitle) {\n failUiAcceptance(`${mode} palace pass did not seed an office title.`);\n }\n if (worldState.activeExam !== null) {\n failUiAcceptance(`${mode} left activeExam populated after submit.`);\n }\n return latest;\n}\n\nasync function runExamLevelAcceptance(page, sessionId, recorder, expectations) {\n await prepareSessionForExam(sessionId, expectations.level);\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, `desktop ${expectations.level}`);\n if (expectations.modalScreenshotName) {\n await recorder.capture(page, expectations.modalScreenshotName);\n }\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay(expectations.level));\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, `desktop ${expectations.level}`);\n await recorder.capture(page, expectations.resultScreenshotName || `desktop-${expectations.level}-result`);\n\n let worldState = await readSession(sessionId);\n const latest = assertLatestExamProgression(worldState, expectations, `desktop ${expectations.level}`);\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertGameLayout(page, `desktop after ${expectations.level}`);\n\n if (expectations.expectedNextLevel) {\n await assertExamCalendarPanel(page, `desktop after ${expectations.level}`, {\n expectedNextLevel: expectations.expectedNextLevel\n });\n } else {\n await assertOfficialCareerPanel(page, \"post-palace official\", {\n expectedPosting: worldState.player.officeTitle\n });\n }\n await assertExamRivalPanel(page, `desktop after ${expectations.level}`, {\n expectedLevel: expectations.level,\n minRivals: 1\n });\n\n worldState = await readSession(sessionId);\n return { latest, worldState };\n}\n\nasync function runRemainingExamProgressionAcceptance(page, sessionId, recorder) {\n await page.setViewportSize(VIEWPORTS.desktop);\n await page.waitForTimeout(150);\n const results = [];\n\n for (const expectations of examProgressionCases.slice(1)) {\n results.push(await runExamLevelAcceptance(page, sessionId, recorder, expectations));\n }\n\n const finalState = results.at(-1)?.worldState || await readSession(sessionId);\n if (finalState.player.role !== \"official\" || finalState.player.examHistory.length < 4) {\n failUiAcceptance(\"complete exam progression did not finish as an official with four exam records.\");\n }\n await assertOfficialCareerPanel(page, \"complete exam progression official\", {\n expectedPosting: finalState.player.officeTitle\n });\n await assertRelationshipPanel(page, \"complete exam progression official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await recorder.capture(page, \"desktop-post-palace-official\");\n\n return {\n finalRank: finalState.player.examRank,\n finalRole: finalState.player.role,\n levels: finalState.player.examHistory.map((entry) => entry.level),\n officeTitle: finalState.player.officeTitle\n };\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runFinalMobileOfficialAcceptance(page, recorder, expectations = {}) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile post-palace official\");\n await assertOfficialCareerPanel(page, \"mobile post-palace official\", {\n expectedPosting: expectations.expectedPosting\n });\n await assertRelationshipPanel(page, \"mobile post-palace official\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n await assertExamRivalPanel(page, \"mobile post-palace official\", {\n expectedLevel: \"palace_exam\",\n minRivals: 1\n });\n await recorder.capture(page, \"mobile-post-palace-official\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile final archive\");\n const archiveLevels = await page.evaluate(() => {\n document.querySelectorAll(\".exam-archive-list .result-section\").forEach((details) => {\n details.open = true;\n });\n return [...document.querySelectorAll(\".exam-archive-entry .exam-calendar-archive\")]\n .map((entry) => entry.innerText)\n .join(\"\\n\");\n });\n for (const label of [\"童试\", \"乡试\", \"会试\", \"殿试\"]) {\n const archiveText = await page.locator(\"#exam-result\").innerText();\n if (!archiveText.includes(label) && !archiveLevels.includes(label)) {\n failUiAcceptance(`mobile final archive is missing ${label}.`);\n }\n }\n await recorder.capture(page, \"mobile-final-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runCheatingExamAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let cheatingSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Cheating\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S38 cheating acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n cheatingSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!cheatingSessionId) throw new Error(\"Cheating start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(cheatingSessionId);\n\n await prepareSessionForExam(cheatingSessionId, \"child_exam\");\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop cheating sample\");\n await page.locator(\"#exam-essay\").fill(buildBrowserCheatingEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop cheating result\");\n\n const resultText = `${await page.locator(\"#exam-title\").innerText()}\\n${await page.locator(\"#exam-result\").innerText()}`;\n if (!resultText.includes(\"监试黜落\") || !resultText.includes(\"疑似照抄\")) {\n failUiAcceptance(\"cheating result did not show severe copy punishment in the browser.\");\n }\n\n const worldState = await readSession(cheatingSessionId);\n const latest = worldState.player.examHistory?.at(-1);\n if (!latest || latest.level !== \"child_exam\") {\n failUiAcceptance(\"cheating sample did not persist a child exam history entry.\");\n }\n if (latest.score?.overall_score !== 0 || latest.promotionResult?.severeCheat !== true) {\n failUiAcceptance(\"cheating sample did not force score 0 with severeCheat=true.\");\n }\n if (worldState.player.examRank !== null || worldState.player.role !== \"scholar\") {\n failUiAcceptance(\"cheating sample advanced the scholar despite severe copying.\");\n }\n if (recorder) {\n await recorder.capture(page, \"desktop-cheating-result\");\n }\n\n return {\n sessionId: cheatingSessionId,\n level: latest.level,\n score: latest.score.overall_score,\n severeCheat: true\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamLevelAcceptance(page, sessionId, recorder, examProgressionCases[0]);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const examProgression = await runRemainingExamProgressionAcceptance(page, sessionId, recorder);\n await runFinalMobileOfficialAcceptance(page, recorder, {\n expectedPosting: examProgression.officeTitle\n });\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const cheatingExam = await runCheatingExamAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n examProgression,\n officialStart,\n cheatingExam,\n roleWorldCoupling,\n identityTurns: [\"scholar\", \"official\", ...roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.role)],\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\n \"desktop\",\n \"mobile\",\n \"four-exam-progression\",\n \"mobile-final-archive\",\n \"cheating-result\",\n \"official-start\",\n \"official-career\",\n \"role-world\"\n ]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n The S38 exam journey expects that server to share this repo's data/sessions directory.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserCheatingEssay,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getMissingExamLevels,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "dec6c9b9058bda783e85327ba9a7cc534163c861": {"t": 1785753979.4214776, "n": 60333, "h": "f79cf7c94cfd9b0a", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\nconst roleWorldAcceptanceCases = Object.freeze([\n {\n role: \"magistrate\",\n playerName: \"Browser Magistrate\",\n action: \"兴修水利\",\n expectedKind: \"magistrate_waterworks\",\n metricPath: \"publicOrder\",\n direction: \"increase\"\n },\n {\n role: \"general\",\n playerName: \"Browser General\",\n action: \"率营出战\",\n expectedKind: \"general_campaign\",\n metricPath: \"borderThreat\",\n direction: \"decrease\"\n },\n {\n role: \"emperor\",\n playerName: \"Browser Emperor\",\n action: \"任免官员整饬吏治\",\n expectedKind: \"emperor_appointments\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n },\n {\n role: \"minister\",\n playerName: \"Browser Minister\",\n action: \"弹劾贪墨官员\",\n expectedKind: \"minister_impeachment\",\n metricPath: \"corruption\",\n direction: \"decrease\"\n }\n]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nasync function waitForInitialRestore(page) {\n try {\n await page.waitForLoadState(\"networkidle\", { timeout: 5000 });\n } catch {\n // The start form visibility check below is the actual acceptance gate.\n }\n}\n\nasync function openCleanStartPage(page, baseUrl) {\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n\n if (!(await page.locator(\"#start-form\").isVisible())) {\n await page.evaluate(() => window.localStorage.removeItem(\"qianqiu.sessionId\"));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await waitForInitialRestore(page);\n }\n\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nfunction getMissingRoleWorldKinds(actualKinds = [], expectedKinds = []) {\n const available = new Set(actualKinds);\n return expectedKinds.filter((kind) => !available.has(kind));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function fetchSessionState(baseUrl, sessionId) {\n const response = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!response.ok) {\n throw new Error(`Session ${sessionId} is not readable through the API: ${response.status}`);\n }\n return response.json();\n}\n\nfunction readStatePath(state, pathExpression) {\n return pathExpression.split(\".\").reduce((value, segment) => value?.[segment], state);\n}\n\nfunction assertMetricDirection(beforeState, afterState, pathExpression, direction, label) {\n const before = readStatePath(beforeState, pathExpression);\n const after = readStatePath(afterState, pathExpression);\n if (typeof before !== \"number\" || typeof after !== \"number\") {\n failUiAcceptance(`${label} metric ${pathExpression} was not numeric before and after the role-world turn.`);\n }\n\n if (direction === \"increase\" && after <= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to increase after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"decrease\" && after >= before) {\n failUiAcceptance(`${label} expected ${pathExpression} to decrease after role-world coupling (${before} -> ${after}).`);\n }\n if (direction === \"change\" && after === before) {\n failUiAcceptance(`${label} expected ${pathExpression} to change after role-world coupling (${before} -> ${after}).`);\n }\n}\n\nasync function startDirectRolePage(context, baseUrl, role, playerName, pageErrors) {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(role);\n await page.locator('input[name=\"playerName\"]').fill(playerName);\n await page.locator('input[name=\"background\"]').fill(`${role} role-world acceptance`);\n await page.locator('textarea[name=\"customSetting\"]').fill(\"S36 browser role-world acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(`${role} role-world start did not write qianqiu.sessionId to localStorage.`);\n return { page, sessionId };\n}\n\nasync function submitTurnAndWaitForIdle(page, action, feedbackSelector) {\n await page.locator(\"#action-input\").fill(action);\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(feedbackSelector).first().waitFor({ state: \"visible\", timeout: 10000 });\n}\n\nasync function runRoleWorldCouplingAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const sessionIds = [];\n const observedKinds = [];\n\n try {\n for (const acceptanceCase of roleWorldAcceptanceCases) {\n const { page, sessionId } = await startDirectRolePage(\n context,\n baseUrl,\n acceptanceCase.role,\n acceptanceCase.playerName,\n pageErrors\n );\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n const beforePayload = await fetchSessionState(baseUrl, sessionId);\n const selector = `.role-world-event[data-role-world-kind=\"${acceptanceCase.expectedKind}\"]`;\n await submitTurnAndWaitForIdle(page, acceptanceCase.action, selector);\n const afterPayload = await fetchSessionState(baseUrl, sessionId);\n\n const roleWorldKinds = await page.locator(\".role-world-event\").evaluateAll((events) =>\n events.map((event) => event.dataset.roleWorldKind).filter(Boolean)\n );\n const missingKinds = getMissingRoleWorldKinds(roleWorldKinds, [acceptanceCase.expectedKind]);\n if (missingKinds.length) {\n failUiAcceptance(`${acceptanceCase.role} role-world feedback missing kinds: ${missingKinds.join(\", \")}.`);\n }\n observedKinds.push(...roleWorldKinds);\n\n assertMetricDirection(\n beforePayload.worldState,\n afterPayload.worldState,\n acceptanceCase.metricPath,\n acceptanceCase.direction,\n acceptanceCase.role\n );\n if (afterPayload.worldState?.roleWorldCoupling?.recentImpacts?.at(-1)?.kind !== acceptanceCase.expectedKind) {\n failUiAcceptance(`${acceptanceCase.role} role-world state did not persist ${acceptanceCase.expectedKind}.`);\n }\n await assertGameLayout(page, `${acceptanceCase.role} role-world`);\n\n if (recorder && acceptanceCase.role === \"magistrate\") {\n await recorder.capture(page, \"role-world-coupling\");\n }\n await page.close();\n }\n\n const expectedKinds = roleWorldAcceptanceCases.map((acceptanceCase) => acceptanceCase.expectedKind);\n const missingKinds = getMissingRoleWorldKinds(observedKinds, expectedKinds);\n if (missingKinds.length) {\n failUiAcceptance(`role-world acceptance missing expected kinds: ${missingKinds.join(\", \")}.`);\n }\n\n return {\n sessionIds,\n kinds: [...new Set(observedKinds)]\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await openCleanStartPage(page, baseUrl);\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n const roleWorldCoupling = await runRoleWorldCouplingAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n roleWorldCoupling,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\", \"official-career\", \"role-world\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingRoleWorldKinds,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "b91a9e77677cea0af4939a04d45288f7610ce566": {"t": 1785753980.8168283, "n": 53042, "h": "3639e272a241478d", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.examCalendarClientWidth > 0 && metrics.examCalendarScrollWidth > metrics.examCalendarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam calendar panel has horizontal scroll overflow (${roundMetric(metrics.examCalendarScrollWidth)}px > ${roundMetric(metrics.examCalendarClientWidth)}px).`\n );\n }\n\n if (metrics.examRivalClientWidth > 0 && metrics.examRivalScrollWidth > metrics.examRivalClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} exam rival panel has horizontal scroll overflow (${roundMetric(metrics.examRivalScrollWidth)}px > ${roundMetric(metrics.examRivalClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n const examCalendar = box(\"#exam-calendar-panel\");\n const examRival = box(\"#exam-rival-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n examCalendarClientWidth: examCalendar?.clientWidth || 0,\n examCalendarScrollWidth: examCalendar?.scrollWidth || 0,\n examCalendarWidth: examCalendar?.width || 0,\n examRivalClientWidth: examRival?.clientWidth || 0,\n examRivalScrollWidth: examRival?.scrollWidth || 0,\n examRivalWidth: examRival?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertExamCalendarPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-calendar-panel\", `${mode} exam calendar panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#exam-calendar-panel\");\n return {\n nextLevel: panel?.dataset.nextLevel || \"\",\n windowStatus: panel?.dataset.windowStatus || \"\",\n monthsUntil: panel?.dataset.monthsUntil || \"\",\n values: document.querySelectorAll(\"#exam-calendar-panel .panel-kicker\").length,\n recommendation: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-recommendation\").length,\n quota: document.querySelectorAll(\"#exam-calendar-panel .exam-calendar-quota\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectedNextLevel && snapshot.nextLevel !== expectations.expectedNextLevel) {\n failUiAcceptance(`${mode} exam calendar expected ${expectations.expectedNextLevel}, got ${snapshot.nextLevel}.`);\n }\n if (expectations.expectedStatus && snapshot.windowStatus !== expectations.expectedStatus) {\n failUiAcceptance(`${mode} exam calendar expected status ${expectations.expectedStatus}, got ${snapshot.windowStatus}.`);\n }\n if (!snapshot.monthsUntil && snapshot.monthsUntil !== \"0\") {\n failUiAcceptance(`${mode} exam calendar did not expose months-until data.`);\n }\n if (snapshot.values < 4 || snapshot.recommendation < 1 || snapshot.quota < 1) {\n failUiAcceptance(`${mode} exam calendar is missing timing, funding, recommendation, or quota details.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam calendar`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function assertExamRivalPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#exam-rival-panel\", `${mode} exam rival panel`);\n\n const snapshot = await page.evaluate(() => {\n const cards = [...document.querySelectorAll(\"#exam-rival-panel .exam-rival-card\")];\n return {\n rivalIds: cards.map((card) => card.dataset.rivalId),\n statuses: cards.map((card) => card.dataset.rivalStatus),\n levels: cards.map((card) => card.dataset.lastLevel),\n contactIds: cards.map((card) => card.dataset.contactId).filter(Boolean),\n latestRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-latest\").length,\n statusRows: document.querySelectorAll(\"#exam-rival-panel .exam-rival-status\").length\n };\n });\n\n if (snapshot.rivalIds.length < (expectations.minRivals || 1)) {\n failUiAcceptance(`${mode} exam rival panel rendered too few rivals.`);\n }\n if (expectations.expectedLevel && !snapshot.levels.includes(expectations.expectedLevel)) {\n failUiAcceptance(`${mode} exam rival panel did not include level ${expectations.expectedLevel}.`);\n }\n if (snapshot.latestRows < snapshot.rivalIds.length || snapshot.statusRows < snapshot.rivalIds.length) {\n failUiAcceptance(`${mode} exam rival panel is missing latest result or status rows.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} exam rival`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n const hasCalendarRequirement = await page.evaluate(() =>\n [...document.querySelectorAll(\"#exam-requirements li\")].some((item) => item.textContent.includes(\"科期\"))\n );\n if (!hasCalendarRequirement) {\n failUiAcceptance(`${mode} exam modal is missing calendar timing details.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n calendarArchive: document.querySelectorAll(\".exam-calendar-archive\").length,\n persistentCandidateNotes: [...document.querySelectorAll(\".candidate-profile .candidate-meta\")]\n .filter((item) => item.textContent.includes(\"科场旧识\")).length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.calendarArchive < 1) {\n failUiAcceptance(`${mode} missing exam calendar archive details.`);\n }\n if (counts.persistentCandidateNotes < 1) {\n failUiAcceptance(`${mode} missing persistent rival notes on candidate profiles.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"mobile scholar\");\n await assertExamRivalPanel(page, \"mobile scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop scholar\", {\n expectedNextLevel: \"child_exam\",\n expectedStatus: \"open\"\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n await assertExamRivalPanel(page, \"desktop scholar after exam\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(page, \"desktop restored scholar\");\n await assertExamRivalPanel(page, \"desktop restored scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertExamCalendarPanel(freshPage, \"fresh page desktop scholar\");\n await assertExamRivalPanel(freshPage, \"fresh page desktop scholar\", {\n expectedLevel: \"child_exam\",\n minRivals: 1\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\", \"official-career\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "48c3cf0d9d0a86dfe714c187bcf83b12c27682fe": {"t": 1785753982.9364815, "n": 47020, "h": "550e7ed3c4396765", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.officialCareerClientWidth > 0 && metrics.officialCareerScrollWidth > metrics.officialCareerClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} official career panel has horizontal scroll overflow (${roundMetric(metrics.officialCareerScrollWidth)}px > ${roundMetric(metrics.officialCareerClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n const officialCareer = box(\"#official-career-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n officialCareerClientWidth: officialCareer?.clientWidth || 0,\n officialCareerScrollWidth: officialCareer?.scrollWidth || 0,\n officialCareerWidth: officialCareer?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nfunction getMissingOfficialCareerOutcomeTypes(actualTypes = [], expectedTypes = []) {\n const available = new Set(actualTypes);\n return expectedTypes.filter((type) => !available.has(type));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertOfficialCareerPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#official-career-panel\", `${mode} official career panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#official-career-panel\");\n const outcomes = [...document.querySelectorAll(\"#official-career-panel .official-career-outcome\")];\n return {\n currentPosting: panel?.dataset.currentPosting || \"\",\n pendingReview: panel?.dataset.pendingReview || \"\",\n outcomeIds: outcomes.map((outcome) => outcome.dataset.outcomeId),\n outcomeTypes: outcomes.map((outcome) => outcome.dataset.outcomeType),\n outcomeStatuses: outcomes.map((outcome) => outcome.dataset.outcomeStatus),\n officeTitles: outcomes.map((outcome) => outcome.dataset.officeTitle),\n outcomeTurns: outcomes.map((outcome) => outcome.dataset.outcomeTurn),\n reasonCount: document.querySelectorAll(\"#official-career-panel .official-career-reason\").length,\n postingCount: document.querySelectorAll(\"#official-career-panel .official-career-posting\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (expectations.expectOutcome && !snapshot.outcomeIds.length) {\n failUiAcceptance(`${mode} official career panel did not render any outcome rows.`);\n }\n\n const missingTypes = getMissingOfficialCareerOutcomeTypes(snapshot.outcomeTypes, expectations.expectedTypes || []);\n if (missingTypes.length) {\n failUiAcceptance(`${mode} official career panel is missing outcome types: ${missingTypes.join(\", \")}.`);\n }\n\n if (expectations.expectedPosting && !snapshot.currentPosting.includes(expectations.expectedPosting)) {\n failUiAcceptance(`${mode} official career panel current posting did not include ${expectations.expectedPosting}.`);\n }\n\n if (snapshot.outcomeIds.length && (snapshot.reasonCount < snapshot.outcomeIds.length || snapshot.postingCount < snapshot.outcomeIds.length)) {\n failUiAcceptance(`${mode} official career panel has incomplete outcome fields.`);\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} official career`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors, recorder = null }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertOfficialCareerPanel(page, \"official start\", {\n expectedPosting: \"候选观政\"\n });\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n await page.locator(\"#action-input\").fill(\"奉上官考成请求实授\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".official-career-event\").first().waitFor({ state: \"visible\", timeout: 10000 });\n const careerSnapshot = await assertOfficialCareerPanel(page, \"official outcome\", {\n expectOutcome: true,\n expectedTypes: [\"appointment\"],\n expectedPosting: \"六部观政进士\"\n });\n if (!careerSnapshot.outcomeStatuses.includes(\"current\")) {\n throw new Error(\"Official career panel did not mark the latest outcome as current.\");\n }\n if (recorder) {\n await recorder.capture(page, \"official-career-outcome\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors,\n recorder\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\", \"official-career\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenRelationshipLeaks,\n getMissingOfficialCareerOutcomeTypes,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "08042a2d20e05aedaa6bdd56ea315f40eafc6571": {"t": 1785753984.6085591, "n": 42956, "h": "e13dd4b61aa9477b", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.activeRequestClientWidth > 0 && metrics.activeRequestScrollWidth > metrics.activeRequestClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} active request panel has horizontal scroll overflow (${roundMetric(metrics.activeRequestScrollWidth)}px > ${roundMetric(metrics.activeRequestClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n const activeRequest = box(\"#active-request-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n activeRequestClientWidth: activeRequest?.clientWidth || 0,\n activeRequestScrollWidth: activeRequest?.scrollWidth || 0,\n activeRequestWidth: activeRequest?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nfunction getMissingActiveRequestTargets(actualIds = [], expectedIds = []) {\n return getMissingRelationshipEntries(actualIds, expectedIds);\n}\n\nfunction getHiddenActiveRequestLeaks(actualIds = [], hiddenIds = []) {\n return getHiddenRelationshipLeaks(actualIds, hiddenIds);\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function assertActiveNpcRequestPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#active-request-panel\", `${mode} active request panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#active-request-panel\");\n const requests = [...document.querySelectorAll(\"#active-request-panel .active-request-card\")];\n return {\n requestIds: requests.map((request) => request.dataset.requestId),\n targetIds: requests.map((request) => request.dataset.targetId),\n targetTypes: requests.map((request) => request.dataset.targetType),\n requestKinds: requests.map((request) => request.dataset.requestKind),\n statuses: requests.map((request) => request.dataset.requestStatus),\n asks: document.querySelectorAll(\"#active-request-panel .active-request-ask\").length,\n stakes: document.querySelectorAll(\"#active-request-panel .active-request-stakes\").length,\n dues: document.querySelectorAll(\"#active-request-panel .active-request-due\").length,\n hints: document.querySelectorAll(\"#active-request-panel .active-request-hint\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel did not render any visible requests.`);\n }\n\n const missing = getMissingActiveRequestTargets(snapshot.targetIds, expectations.expectedTargetIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} active request panel is missing expected targets: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenActiveRequestLeaks(snapshot.targetIds, expectations.hiddenTargetIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} active request panel rendered hidden targets: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} active request panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"asks\", snapshot.asks],\n [\"stakes\", snapshot.stakes],\n [\"due turns\", snapshot.dues],\n [\"hints\", snapshot.hints]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.requestIds.length) {\n failUiAcceptance(`${mode} active request panel has ${count} ${label} for ${snapshot.requestIds.length} requests.`);\n }\n }\n\n if (expectations.expectedTargetTypes) {\n for (const type of expectations.expectedTargetTypes) {\n if (!snapshot.targetTypes.includes(type)) {\n failUiAcceptance(`${mode} active request panel is missing target type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} active request`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"mobile scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop scholar after turn\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(page, \"desktop restored scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await assertActiveNpcRequestPanel(freshPage, \"fresh page desktop scholar\", {\n expectedTargetIds: [\"C01\"],\n expectedTargetTypes: [\"character\"],\n hiddenTargetIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenActiveRequestLeaks,\n getHiddenRelationshipLeaks,\n getMissingActiveRequestTargets,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "cefde6a20fac781db27ddf8491b1a628341e0a4e": {"t": 1785753986.0507128, "n": 38089, "h": "d924baf7fbf5d6c9", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.relationshipClientWidth > 0 && metrics.relationshipScrollWidth > metrics.relationshipClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} relationship panel has horizontal scroll overflow (${roundMetric(metrics.relationshipScrollWidth)}px > ${roundMetric(metrics.relationshipClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n const relationship = box(\"#relationship-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n relationshipClientWidth: relationship?.clientWidth || 0,\n relationshipScrollWidth: relationship?.scrollWidth || 0,\n relationshipWidth: relationship?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nfunction getMissingRelationshipEntries(actualIds = [], expectedIds = []) {\n const available = new Set(actualIds);\n return expectedIds.filter((id) => !available.has(id));\n}\n\nfunction getHiddenRelationshipLeaks(actualIds = [], hiddenIds = []) {\n const available = new Set(actualIds);\n return hiddenIds.filter((id) => available.has(id));\n}\n\nasync function assertRelationshipPanel(page, mode, expectations = {}) {\n await visibleBox(page, \"#relationship-panel\", `${mode} relationship panel`);\n\n const snapshot = await page.evaluate(() => {\n const panel = document.querySelector(\"#relationship-panel\");\n const contacts = [...document.querySelectorAll(\"#relationship-panel .relationship-contact\")];\n return {\n contactIds: contacts.map((contact) => contact.dataset.contactId),\n contactTypes: contacts.map((contact) => contact.dataset.contactType),\n relationshipScores: document.querySelectorAll(\"#relationship-panel .relationship-score\").length,\n resentmentScores: document.querySelectorAll(\"#relationship-panel .relationship-resentment\").length,\n stances: document.querySelectorAll(\"#relationship-panel .relationship-stance\").length,\n intents: document.querySelectorAll(\"#relationship-panel .relationship-intent\").length,\n sources: document.querySelectorAll(\"#relationship-panel .relationship-source\").length,\n updated: document.querySelectorAll(\"#relationship-panel .relationship-updated\").length,\n text: panel?.innerText || \"\"\n };\n });\n\n if (!snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel did not render any visible contacts.`);\n }\n\n const missing = getMissingRelationshipEntries(snapshot.contactIds, expectations.expectedIds || []);\n if (missing.length) {\n failUiAcceptance(`${mode} relationship panel is missing expected entries: ${missing.join(\", \")}.`);\n }\n\n const hiddenLeaks = getHiddenRelationshipLeaks(snapshot.contactIds, expectations.hiddenIds || []);\n if (hiddenLeaks.length) {\n failUiAcceptance(`${mode} relationship panel rendered hidden entries: ${hiddenLeaks.join(\", \")}.`);\n }\n\n for (const token of expectations.hiddenTextTokens || []) {\n if (snapshot.text.includes(token)) {\n failUiAcceptance(`${mode} relationship panel leaked hidden text token: ${token}.`);\n }\n }\n\n const fieldCounts = [\n [\"relationship scores\", snapshot.relationshipScores],\n [\"resentment scores\", snapshot.resentmentScores],\n [\"stances\", snapshot.stances],\n [\"intents\", snapshot.intents],\n [\"sources\", snapshot.sources],\n [\"updated turns\", snapshot.updated]\n ];\n for (const [label, count] of fieldCounts) {\n if (count < snapshot.contactIds.length) {\n failUiAcceptance(`${mode} relationship panel has ${count} ${label} for ${snapshot.contactIds.length} contacts.`);\n }\n }\n\n if (expectations.expectedTypes) {\n for (const type of expectations.expectedTypes) {\n if (!snapshot.contactTypes.includes(type)) {\n failUiAcceptance(`${mode} relationship panel is missing contact type: ${type}.`);\n }\n }\n }\n\n const layoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), `${mode} relationship`);\n if (layoutFailures.length) {\n failUiAcceptance(layoutFailures.join(\" \"));\n }\n\n return snapshot;\n}\n\nasync function runRelationshipTurnAcceptance(page) {\n const beforeRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n\n await page.locator(\"#action-input\").fill(\"研读经书,向塾师请益\");\n await page.locator(\"#action-btn\").click();\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#action-btn\");\n return button && !button.disabled;\n }, null, { timeout: 15000 });\n await page.locator(\".relationship-change\").first().waitFor({ state: \"visible\", timeout: 10000 });\n\n const afterRelationship = await page.locator(\n '#relationship-panel .relationship-contact[data-contact-type=\"character\"][data-contact-id=\"C01\"]'\n ).getAttribute(\"data-relationship\");\n if (Number(afterRelationship) <= Number(beforeRelationship)) {\n failUiAcceptance(`relationship panel did not update mentor relationship after a Mock turn (${beforeRelationship} -> ${afterRelationship}).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await assertRelationshipPanel(page, \"mobile scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n await assertRelationshipPanel(page, \"official start\", {\n expectedIds: [\"C01\", \"eunuchs\", \"scholarOfficials\", \"militaryLords\"],\n expectedTypes: [\"character\", \"faction\"]\n });\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await assertRelationshipPanel(page, \"desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await runRelationshipTurnAcceptance(page);\n await assertRelationshipPanel(page, \"desktop scholar after turn\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n await assertRelationshipPanel(page, \"desktop restored scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n await assertRelationshipPanel(freshPage, \"fresh page desktop scholar\", {\n expectedIds: [\"C01\", \"scholarOfficials\"],\n expectedTypes: [\"character\", \"faction\"],\n hiddenIds: [\"eunuchs\", \"militaryLords\"],\n hiddenTextTokens: [\"Eunuch faction\", \"Military faction\"]\n });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getHiddenRelationshipLeaks,\n getMissingRelationshipEntries,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "9cdbf910964ce4d4a8c4ad61d16ca0287167f30b": {"t": 1785753987.481499, "n": 31529, "h": "41b509c6345452c5", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\nconst requiredStartRoles = Object.freeze([\"scholar\", \"emperor\", \"minister\", \"general\", \"magistrate\", \"official\"]);\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getMissingStartRoles(roleValues = []) {\n const availableRoles = new Set(roleValues);\n return requiredStartRoles.filter((role) => !availableRoles.has(role));\n}\n\nasync function assertStartRoleOptions(page) {\n const startRoleValues = await page.locator('select[name=\"role\"] option').evaluateAll((options) =>\n options.map((option) => option.value)\n );\n const missingStartRoles = getMissingStartRoles(startRoleValues);\n if (missingStartRoles.length) {\n throw new Error(`Start form missing role options: ${missingStartRoles.join(\", \")}`);\n }\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runOfficialStartAcceptance(browser, { baseUrl, onSessionId, pageErrors }) {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n let officialSessionId = null;\n\n try {\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"official\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Official\");\n await page.locator('input[name=\"background\"]').fill(\"newly ranked jinshi\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"official direct start acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n officialSessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!officialSessionId) throw new Error(\"Official start did not write qianqiu.sessionId to localStorage.\");\n onSessionId(officialSessionId);\n\n const rolePanelText = await page.locator(\"#scholar-panel\").innerText();\n if (!rolePanelText.includes(\"入仕官员\") || !rolePanelText.includes(\"候选观政\")) {\n throw new Error(\"Official start did not render the official role panel.\");\n }\n\n const actionPlaceholder = await page.locator(\"#action-input\").getAttribute(\"placeholder\");\n if (!actionPlaceholder || !actionPlaceholder.includes(\"奉上官\")) {\n throw new Error(\"Official start did not render the official action placeholder.\");\n }\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${officialSessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Official start session is not readable through the API: ${stateResponse.status}`);\n }\n const statePayload = await stateResponse.json();\n if (statePayload.worldState?.player?.role !== \"official\") {\n throw new Error(\"Official start API state did not persist player.role = official.\");\n }\n\n return {\n sessionId: officialSessionId,\n statusText: (await page.locator(\"#status-strip\").innerText()).replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await context.close();\n }\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n const sessionIds = [];\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n await assertStartRoleOptions(page);\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n sessionIds.push(sessionId);\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n const officialStart = await runOfficialStartAcceptance(browser, {\n baseUrl,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n onSessionId(createdSessionId);\n },\n pageErrors\n });\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n sessionIds,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n officialStart,\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\", \"official-start\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n const sessionIds = [];\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionIds.push(createdSessionId);\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n const idsToClean = new Set([...(result?.sessionIds || []), result?.sessionId, ...sessionIds].filter(Boolean));\n for (const id of idsToClean) {\n await cleanupSession(id);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n getMissingStartRoles,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "0d52d4655eaf47a0c88690aeb8c688442693d38b": {"t": 1785753988.7704039, "n": 27740, "h": "cf0455af53d9c01b", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\nconst desktopLayoutBreakpoint = 820;\nconst desktopGameMinAppShare = 0.86;\nconst desktopGameMinViewportShare = 0.74;\nconst desktopViewportShareOnlyWhenAppShare = 0.88;\nconst horizontalClipTolerance = 4;\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nfunction roundMetric(value) {\n return Math.round(Number(value) || 0);\n}\n\nfunction getGameLayoutFailures(metrics, mode = \"game\") {\n const failures = [];\n if (!metrics) return [`${mode} layout metrics were not collected.`];\n\n const isDesktop =\n metrics.viewportWidth > desktopLayoutBreakpoint &&\n metrics.clientWidth > desktopLayoutBreakpoint;\n const appWidth = Number(metrics.appWidth) || 0;\n const gameWidth = Number(metrics.gameWidth) || 0;\n const viewportWidth = Number(metrics.viewportWidth) || 0;\n\n if (isDesktop) {\n const appShare = appWidth > 0 ? gameWidth / appWidth : 0;\n const viewportShare = viewportWidth > 0 ? gameWidth / viewportWidth : 0;\n const appUsesViewport = viewportWidth > 0 && appWidth / viewportWidth >= desktopViewportShareOnlyWhenAppShare;\n\n if (appShare < desktopGameMinAppShare) {\n failures.push(\n `${mode} game panel is too narrow (${roundMetric(gameWidth)}px, ${Math.round(appShare * 100)}% of app shell ${roundMetric(appWidth)}px).`\n );\n }\n if (appUsesViewport && viewportShare < desktopGameMinViewportShare) {\n failures.push(\n `${mode} game panel is too narrow for the desktop viewport (${roundMetric(gameWidth)}px, ${Math.round(viewportShare * 100)}% of viewport ${roundMetric(viewportWidth)}px).`\n );\n }\n }\n\n if (metrics.gameScrollWidth > metrics.gameClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} game panel clips horizontal content (${roundMetric(metrics.gameScrollWidth)}px scroll width > ${roundMetric(metrics.gameClientWidth)}px client width).`\n );\n }\n\n if (metrics.scholarScrollWidth > metrics.scholarClientWidth + horizontalClipTolerance) {\n failures.push(\n `${mode} role panel has horizontal scroll overflow (${roundMetric(metrics.scholarScrollWidth)}px > ${roundMetric(metrics.scholarClientWidth)}px).`\n );\n }\n\n if (metrics.scholarLeft < metrics.gameLeft - horizontalClipTolerance) {\n failures.push(`${mode} role panel starts outside the game panel.`);\n }\n if (metrics.scholarRight > metrics.gameRight + horizontalClipTolerance) {\n failures.push(`${mode} role panel extends outside the game panel.`);\n }\n\n return failures;\n}\n\nasync function readGameLayoutMetrics(page) {\n return page.evaluate(() => {\n const box = (selector) => {\n const element = document.querySelector(selector);\n if (!element) return null;\n const rect = element.getBoundingClientRect();\n return {\n clientWidth: element.clientWidth,\n height: rect.height,\n left: rect.left,\n right: rect.right,\n scrollWidth: element.scrollWidth,\n width: rect.width\n };\n };\n const app = box(\".app-shell\");\n const game = box(\".game-panel\");\n const scholar = box(\"#scholar-panel\");\n\n return {\n appWidth: app?.width || 0,\n clientWidth: document.documentElement.clientWidth,\n gameClientWidth: game?.clientWidth || 0,\n gameLeft: game?.left || 0,\n gameRight: game?.right || 0,\n gameScrollWidth: game?.scrollWidth || 0,\n gameWidth: game?.width || 0,\n scholarClientWidth: scholar?.clientWidth || 0,\n scholarLeft: scholar?.left || 0,\n scholarRight: scholar?.right || 0,\n scholarScrollWidth: scholar?.scrollWidth || 0,\n scholarWidth: scholar?.width || 0,\n viewportWidth: window.innerWidth\n };\n });\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n const gameLayoutFailures = getGameLayoutFailures(await readGameLayoutMetrics(page), mode);\n if (gameLayoutFailures.length) {\n failUiAcceptance(gameLayoutFailures.join(\" \"));\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n let sessionId = null;\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionId = createdSessionId;\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n if (result?.sessionId || sessionId) {\n await cleanupSession(result?.sessionId || sessionId);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n getGameLayoutFailures,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "434b3ef89d7fc7928fa9a3ade5ae63e4cb725c14": {"t": 1785753990.3792756, "n": 23964, "h": "9e9ff44eebd239e7", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\nconst pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst minimumScreenshotBytes = 1200;\n\nconst VIEWPORTS = {\n desktop: { width: 1280, height: 900 },\n mobile: { width: 390, height: 844 }\n};\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n screenshotsDir: null,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else if (arg === \"--screenshots\") {\n args.screenshotsDir = readArgValue(argv, index, \"--screenshots\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction failUiAcceptance(message) {\n throw new Error(`UI acceptance failed: ${message}`);\n}\n\nfunction sanitizeScreenshotName(name) {\n return String(name || \"screenshot\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"screenshot\";\n}\n\nfunction resolveScreenshotDir(screenshotsDir) {\n if (!screenshotsDir) return null;\n return path.isAbsolute(screenshotsDir) ? screenshotsDir : path.join(rootDir, screenshotsDir);\n}\n\nfunction assertPngScreenshot(buffer, name = \"screenshot\") {\n if (!Buffer.isBuffer(buffer)) {\n failUiAcceptance(`${name} did not produce a screenshot buffer.`);\n }\n\n if (buffer.length < minimumScreenshotBytes) {\n failUiAcceptance(`${name} screenshot is unexpectedly small (${buffer.length} bytes).`);\n }\n\n for (let index = 0; index < pngSignature.length; index += 1) {\n if (buffer[index] !== pngSignature[index]) {\n failUiAcceptance(`${name} screenshot is not a PNG image.`);\n }\n }\n}\n\nfunction createScreenshotRecorder(screenshotsDir) {\n const outputDir = resolveScreenshotDir(screenshotsDir);\n const captures = [];\n\n return {\n async capture(page, name) {\n const buffer = await page.screenshot({ animations: \"disabled\", fullPage: false });\n assertPngScreenshot(buffer, name);\n\n let filePath = null;\n if (outputDir) {\n await fsp.mkdir(outputDir, { recursive: true });\n filePath = path.join(\n outputDir,\n `${String(captures.length + 1).padStart(2, \"0\")}-${sanitizeScreenshotName(name)}.png`\n );\n await fsp.writeFile(filePath, buffer);\n }\n\n captures.push({ name, bytes: buffer.length, filePath });\n },\n summary() {\n return captures.slice();\n }\n };\n}\n\nfunction rectsOverlap(first, second, tolerance = 0) {\n return !(\n first.x + first.width <= second.x + tolerance ||\n second.x + second.width <= first.x + tolerance ||\n first.y + first.height <= second.y + tolerance ||\n second.y + second.height <= first.y + tolerance\n );\n}\n\nfunction rectWithinViewport(rect, viewport, tolerance = 2) {\n return (\n rect.x >= -tolerance &&\n rect.y >= -tolerance &&\n rect.x + rect.width <= viewport.width + tolerance &&\n rect.y + rect.height <= viewport.height + tolerance\n );\n}\n\nasync function visibleBox(page, selector, label) {\n const locator = page.locator(selector).first();\n await locator.waitFor({ state: \"visible\", timeout: 10000 });\n const box = await locator.boundingBox();\n if (!box || box.width <= 0 || box.height <= 0) {\n failUiAcceptance(`${label} is visible but has no measurable layout box.`);\n }\n return box;\n}\n\nasync function assertNoHorizontalOverflow(page, label) {\n const metrics = await page.evaluate(() => ({\n bodyScrollWidth: document.body.scrollWidth,\n clientWidth: document.documentElement.clientWidth,\n documentScrollWidth: document.documentElement.scrollWidth,\n innerWidth: window.innerWidth\n }));\n const scrollWidth = Math.max(metrics.bodyScrollWidth, metrics.documentScrollWidth);\n if (scrollWidth > metrics.clientWidth + 4 || scrollWidth > metrics.innerWidth + 4) {\n failUiAcceptance(`${label} has horizontal page overflow (${scrollWidth}px > ${metrics.clientWidth}px).`);\n }\n}\n\nasync function assertGameLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} game layout`);\n\n const viewport = page.viewportSize();\n const status = await visibleBox(page, \"#status-strip\", `${mode} status strip`);\n const scholar = await visibleBox(page, \"#scholar-panel\", `${mode} role panel`);\n const narrativeBox = await visibleBox(page, \"#narrative\", `${mode} narrative`);\n const actionArea = await visibleBox(page, \"#action-area\", `${mode} action area`);\n const actionInput = await visibleBox(page, \"#action-input\", `${mode} action input`);\n const actionButton = await visibleBox(page, \"#action-btn\", `${mode} action button`);\n\n if (!rectWithinViewport(actionArea, viewport, mode === \"mobile\" ? 3 : 12)) {\n failUiAcceptance(`${mode} action area does not fit inside the viewport.`);\n }\n if (rectsOverlap(status, scholar)) {\n failUiAcceptance(`${mode} status strip overlaps the role panel.`);\n }\n if (rectsOverlap(scholar, narrativeBox)) {\n failUiAcceptance(`${mode} role panel overlaps the narrative area.`);\n }\n if (rectsOverlap(narrativeBox, actionArea)) {\n failUiAcceptance(`${mode} narrative area overlaps the action surface.`);\n }\n if (rectsOverlap(actionInput, actionButton)) {\n failUiAcceptance(`${mode} action textarea overlaps the action button.`);\n }\n\n const computed = await page.evaluate(() => {\n const action = getComputedStyle(document.querySelector(\"#action-area\"));\n const start = getComputedStyle(document.querySelector(\".start-panel\"));\n return {\n actionDisplay: action.display,\n actionFlexDirection: action.flexDirection,\n actionPosition: action.position,\n startDisplay: start.display\n };\n });\n\n if (computed.startDisplay !== \"none\") {\n failUiAcceptance(`${mode} start panel is still occupying the game view.`);\n }\n if (computed.actionDisplay !== \"flex\") {\n failUiAcceptance(`${mode} action area is not using the expected flex layout.`);\n }\n\n if (mode === \"mobile\") {\n if (computed.actionFlexDirection !== \"column\") {\n failUiAcceptance(\"mobile action controls should stack vertically.\");\n }\n if (computed.actionPosition !== \"sticky\") {\n failUiAcceptance(\"mobile action area should remain sticky at the bottom.\");\n }\n if (actionButton.y < actionInput.y + actionInput.height - 2) {\n failUiAcceptance(\"mobile action button should sit below the textarea.\");\n }\n } else if (actionButton.x < actionInput.x + actionInput.width - 2) {\n failUiAcceptance(\"desktop action button should sit beside the textarea.\");\n }\n}\n\nasync function assertExamWritingLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam writing modal`);\n\n const viewport = page.viewportSize();\n const modal = await visibleBox(page, \".exam-modal\", `${mode} exam modal`);\n const question = await visibleBox(page, \"#exam-question\", `${mode} exam question`);\n const requirements = await visibleBox(page, \"#exam-requirements\", `${mode} exam requirements`);\n const tools = await visibleBox(page, \"#exam-writing-tools\", `${mode} exam writing tools`);\n const essay = await visibleBox(page, \"#exam-essay\", `${mode} exam essay textarea`);\n const submit = await visibleBox(page, \"#exam-submit\", `${mode} exam submit button`);\n\n if (!rectWithinViewport(modal, viewport, mode === \"mobile\" ? 8 : 30)) {\n failUiAcceptance(`${mode} exam modal does not fit inside the viewport.`);\n }\n if (rectsOverlap(question, requirements)) {\n failUiAcceptance(`${mode} exam question overlaps the requirements list.`);\n }\n if (rectsOverlap(requirements, tools)) {\n failUiAcceptance(`${mode} exam requirements overlap the writing tools.`);\n }\n if (rectsOverlap(tools, essay)) {\n failUiAcceptance(`${mode} exam writing tools overlap the essay textarea.`);\n }\n if (rectsOverlap(essay, submit)) {\n failUiAcceptance(`${mode} exam essay textarea overlaps the submit button.`);\n }\n}\n\nasync function assertExamResultLayout(page, mode) {\n await assertNoHorizontalOverflow(page, `${mode} exam result modal`);\n\n const modal = await visibleBox(page, \".exam-modal\", `${mode} result modal`);\n const result = await visibleBox(page, \"#exam-result\", `${mode} exam result`);\n const summary = await visibleBox(page, \".result-summary, .exam-archive-list\", `${mode} result summary`);\n\n if (rectsOverlap(summary, result, -4) && summary.y < result.y - 2) {\n failUiAcceptance(`${mode} result summary has an unexpected layout position.`);\n }\n\n const counts = await page.evaluate(() => {\n document.querySelectorAll(\".result-section\").forEach((details) => {\n details.open = true;\n });\n const resultElement = document.querySelector(\"#exam-result\");\n const examQuestion = document.querySelector(\"#exam-question\");\n const examRequirements = document.querySelector(\"#exam-requirements\");\n const examEssay = document.querySelector(\"#exam-essay\");\n return {\n candidateProfiles: document.querySelectorAll(\".candidate-profile\").length,\n essayDisplay: examEssay ? getComputedStyle(examEssay).display : \"\",\n questionDisplay: examQuestion ? getComputedStyle(examQuestion).display : \"\",\n requirementsDisplay: examRequirements ? getComputedStyle(examRequirements).display : \"\",\n playerArchive: document.querySelectorAll(\".player-exam-archive\").length,\n playerRanking: document.querySelectorAll(\".ranking-list .is-player\").length,\n resultSections: document.querySelectorAll(\".result-section\").length,\n resultClientWidth: resultElement?.clientWidth || 0,\n resultScrollWidth: resultElement?.scrollWidth || 0\n };\n });\n\n if (counts.resultSections < 5) {\n failUiAcceptance(`${mode} expected at least five result detail sections, found ${counts.resultSections}.`);\n }\n if (counts.playerArchive < 1) {\n failUiAcceptance(`${mode} missing the player essay archive section.`);\n }\n if (counts.playerRanking < 1) {\n failUiAcceptance(`${mode} missing the highlighted player ranking row.`);\n }\n if (counts.candidateProfiles < 1) {\n failUiAcceptance(`${mode} missing inspectable same-field candidate essays.`);\n }\n if (counts.questionDisplay !== \"none\" || counts.requirementsDisplay !== \"none\" || counts.essayDisplay !== \"none\") {\n failUiAcceptance(`${mode} still shows question or writing controls behind the result view.`);\n }\n if (counts.resultScrollWidth > counts.resultClientWidth + 4) {\n failUiAcceptance(`${mode} exam result content overflows horizontally.`);\n }\n if (result.y + result.height > modal.y + modal.height + 4) {\n failUiAcceptance(`${mode} exam result content escapes the modal.`);\n }\n}\n\nfunction buildBrowserSmokeEssay() {\n return [\n \"On rites and corn stores, the student urges soft tax, clear rolls, modest yamen work, and steady school books. \",\n \"The text says local officers should hear poor folk soon, store crops before hunger, use honest clerks, and punish with care. \",\n \"It ends by asking old laws to guide households, farms, teachers, and county order.\"\n ].join(\"\");\n}\n\nasync function runExamUiAcceptance(page, recorder) {\n await page.locator(\"#scholar-panel .panel-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamWritingLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-modal\");\n\n await page.locator(\"#exam-essay\").fill(buildBrowserSmokeEssay());\n await page.waitForFunction(() => {\n const button = document.querySelector(\"#exam-submit\");\n return button && !button.disabled;\n });\n await page.locator(\"#exam-submit\").click();\n await page.locator(\"#exam-result\").waitFor({ state: \"visible\", timeout: 15000 });\n await assertExamResultLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-exam-result\");\n\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runMobileUiAcceptance(page, recorder) {\n await page.setViewportSize(VIEWPORTS.mobile);\n await page.waitForTimeout(150);\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await page.waitForTimeout(100);\n await assertGameLayout(page, \"mobile\");\n await recorder.capture(page, \"mobile-game-layout\");\n\n await page.locator(\"#scholar-panel .archive-action\").first().click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertExamResultLayout(page, \"mobile archive\");\n await recorder.capture(page, \"mobile-exam-archive\");\n await page.locator(\"#exam-close\").click();\n await page.locator(\"#exam-backdrop\").waitFor({ state: \"hidden\", timeout: 10000 });\n}\n\nasync function runBrowserJourney({\n baseUrl,\n browserPath,\n headed = false,\n onSessionId = () => {},\n screenshotsDir = null\n} = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n const recorder = createScreenshotRecorder(screenshotsDir);\n let sessionId = null;\n\n try {\n const context = await browser.newContext({ viewport: VIEWPORTS.desktop });\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n onSessionId(sessionId);\n\n await assertGameLayout(page, \"desktop\");\n await recorder.capture(page, \"desktop-game-layout\");\n await runExamUiAcceptance(page, recorder);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(page, \"desktop restored\");\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n await assertGameLayout(freshPage, \"fresh page desktop\");\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n await runMobileUiAcceptance(page, recorder);\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n statusText: statusText.replace(/\\s+/g, \" \").trim(),\n uiAcceptance: {\n screenshots: recorder.summary(),\n viewports: [\"desktop\", \"mobile\"]\n }\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n let sessionId = null;\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionId = createdSessionId;\n },\n screenshotsDir: options.screenshotsDir\n });\n return result;\n } finally {\n if (result?.sessionId || sessionId) {\n await cleanupSession(result?.sessionId || sessionId);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --screenshots <dir>\n Save desktop/mobile UI acceptance screenshots to this directory.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n console.log(`UI acceptance: ${result.uiAcceptance.viewports.join(\", \")} (${result.uiAcceptance.screenshots.length} screenshots checked)`);\n const saved = result.uiAcceptance.screenshots.filter((screenshot) => screenshot.filePath);\n if (saved.length) {\n console.log(`Screenshots: ${path.dirname(saved[0].filePath)}`);\n }\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n assertPngScreenshot,\n buildBrowserSmokeEssay,\n createScreenshotRecorder,\n getDefaultBrowserCandidates,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n rectsOverlap,\n resolveBrowserExecutable,\n sanitizeScreenshotName,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}, "b22a05163fe83e0a007a8a7fab28952d56f87cf2": {"t": 1785753991.634136, "n": 10550, "h": "2d53a416764f573f", "c": "const fs = require(\"node:fs\");\nconst fsp = require(\"node:fs/promises\");\nconst net = require(\"node:net\");\nconst path = require(\"node:path\");\nconst { spawn } = require(\"node:child_process\");\nconst { once } = require(\"node:events\");\nconst { chromium } = require(\"playwright-core\");\n\nconst rootDir = path.join(__dirname, \"..\");\nconst sessionIdPattern = /^[0-9a-fA-F-]{20,}$/;\n\nfunction parseBrowserSmokeArgs(argv = process.argv) {\n const args = {\n browserPath: null,\n headed: false,\n help: false,\n url: null\n };\n\n for (let index = 2; index < argv.length; index += 1) {\n const arg = argv[index];\n if (arg === \"--help\" || arg === \"-h\") {\n args.help = true;\n } else if (arg === \"--headed\") {\n args.headed = true;\n } else if (arg === \"--url\") {\n args.url = normalizeBaseUrl(readArgValue(argv, index, \"--url\"));\n index += 1;\n } else if (arg === \"--browser\") {\n args.browserPath = readArgValue(argv, index, \"--browser\");\n index += 1;\n } else {\n throw new Error(`Unknown browser smoke argument: ${arg}`);\n }\n }\n\n return args;\n}\n\nfunction readArgValue(argv, index, flag) {\n const value = argv[index + 1];\n if (!value || value.startsWith(\"--\")) {\n throw new Error(`${flag} requires a value`);\n }\n return value;\n}\n\nfunction normalizeBaseUrl(value) {\n const url = new URL(value);\n url.hash = \"\";\n url.search = \"\";\n return url.toString().replace(/\\/$/, \"\");\n}\n\nfunction getDefaultBrowserCandidates(platform = process.platform, env = process.env) {\n if (platform === \"win32\") {\n return [\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Google\", \"Chrome\", \"Application\", \"chrome.exe\"),\n path.join(env[\"ProgramFiles(x86)\"] || \"C:\\\\Program Files (x86)\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\"),\n path.join(env.ProgramFiles || \"C:\\\\Program Files\", \"Microsoft\", \"Edge\", \"Application\", \"msedge.exe\")\n ];\n }\n\n if (platform === \"darwin\") {\n return [\n \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n \"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge\",\n \"/Applications/Chromium.app/Contents/MacOS/Chromium\"\n ];\n }\n\n return [\n \"/usr/bin/google-chrome\",\n \"/usr/bin/google-chrome-stable\",\n \"/usr/bin/chromium\",\n \"/usr/bin/chromium-browser\",\n \"/usr/bin/microsoft-edge\"\n ];\n}\n\nfunction resolveBrowserExecutable(options = {}) {\n const env = options.env || process.env;\n const platform = options.platform || process.platform;\n const exists = options.exists || fs.existsSync;\n const explicitPath = options.browserPath || env.BROWSER_EXECUTABLE_PATH;\n\n if (explicitPath) {\n if (exists(explicitPath)) return explicitPath;\n throw new Error(`Browser executable not found: ${explicitPath}`);\n }\n\n const candidate = getDefaultBrowserCandidates(platform, env).find((candidatePath) => exists(candidatePath));\n if (candidate) return candidate;\n\n throw new Error(\n \"No Chrome/Edge executable found. Install Chrome or Edge, set BROWSER_EXECUTABLE_PATH, or pass --browser <path>.\"\n );\n}\n\nasync function getOpenPort() {\n const server = net.createServer();\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", resolve);\n });\n const { port } = server.address();\n await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));\n return port;\n}\n\nasync function startLocalServer() {\n const port = await getOpenPort();\n const baseUrl = `http://127.0.0.1:${port}`;\n const child = spawn(process.execPath, [\"server.js\"], {\n cwd: rootDir,\n env: {\n ...process.env,\n AI_PROVIDER: \"mock\",\n PORT: String(port)\n },\n stdio: [\"ignore\", \"pipe\", \"pipe\"]\n });\n\n let output = \"\";\n child.stdout.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n child.stderr.on(\"data\", (chunk) => {\n output += chunk.toString();\n });\n\n await waitForHealth(baseUrl, child, () => output);\n\n return {\n baseUrl,\n stop: () => stopChild(child)\n };\n}\n\nasync function waitForHealth(baseUrl, child, getOutput, timeoutMs = 15000) {\n const deadline = Date.now() + timeoutMs;\n let lastError = null;\n\n while (Date.now() < deadline) {\n if (child.exitCode !== null) {\n throw new Error(`Local server exited before health check passed.\\n${getOutput()}`);\n }\n\n try {\n const response = await fetch(`${baseUrl}/api/health`);\n if (response.ok) return;\n } catch (error) {\n lastError = error;\n }\n\n await delay(200);\n }\n\n throw new Error(`Timed out waiting for ${baseUrl}/api/health${lastError ? `: ${lastError.message}` : \"\"}`);\n}\n\nasync function stopChild(child) {\n if (!child || child.exitCode !== null) return;\n child.kill();\n let timeoutId = null;\n try {\n const timeout = new Promise((resolve) => {\n timeoutId = setTimeout(() => {\n if (child.exitCode === null) child.kill(\"SIGKILL\");\n resolve();\n }, 3000);\n if (typeof timeoutId.unref === \"function\") timeoutId.unref();\n });\n await Promise.race([once(child, \"exit\"), timeout]);\n } finally {\n if (timeoutId) clearTimeout(timeoutId);\n }\n}\n\nfunction delay(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function runBrowserJourney({ baseUrl, browserPath, headed = false, onSessionId = () => {} } = {}) {\n const browser = await chromium.launch({\n executablePath: browserPath,\n headless: !headed\n });\n\n const pageErrors = [];\n let sessionId = null;\n\n try {\n const context = await browser.newContext();\n const page = await context.newPage();\n page.on(\"pageerror\", (error) => pageErrors.push(error.message));\n\n await page.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await page.locator(\"#start-form\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#action-area\").waitFor({ state: \"hidden\", timeout: 10000 });\n\n await page.locator('input[name=\"dynasty\"]').fill(\"Ming\");\n await page.locator('input[name=\"year\"]').fill(\"1644\");\n await page.locator('select[name=\"role\"]').selectOption(\"scholar\");\n await page.locator('input[name=\"playerName\"]').fill(\"Browser Smoke\");\n await page.locator('input[name=\"background\"]').fill(\"county school student\");\n await page.locator('textarea[name=\"customSetting\"]').fill(\"browser smoke acceptance run\");\n await page.locator('#start-form button[type=\"submit\"]').click();\n\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n sessionId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (!sessionId) throw new Error(\"Start flow did not write qianqiu.sessionId to localStorage.\");\n onSessionId(sessionId);\n\n await page.reload({ waitUntil: \"domcontentloaded\" });\n await page.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await page.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n\n const restoredId = await page.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (restoredId !== sessionId) {\n throw new Error(`Restored localStorage session mismatch: expected ${sessionId}, got ${restoredId}`);\n }\n\n const freshPage = await context.newPage();\n freshPage.on(\"pageerror\", (error) => pageErrors.push(error.message));\n await freshPage.goto(baseUrl, { waitUntil: \"domcontentloaded\" });\n await freshPage.locator(\"#action-area\").waitFor({ state: \"visible\", timeout: 10000 });\n await freshPage.locator(\"#scholar-panel\").waitFor({ state: \"visible\", timeout: 10000 });\n const freshPageId = await freshPage.evaluate(() => window.localStorage.getItem(\"qianqiu.sessionId\"));\n if (freshPageId !== sessionId) {\n throw new Error(`Fresh page localStorage session mismatch: expected ${sessionId}, got ${freshPageId}`);\n }\n await freshPage.close();\n\n const stateResponse = await fetch(`${baseUrl}/api/game/state/${sessionId}`);\n if (!stateResponse.ok) {\n throw new Error(`Restored session is not readable through the API: ${stateResponse.status}`);\n }\n\n if (pageErrors.length) {\n throw new Error(`Browser page errors detected: ${pageErrors.join(\"; \")}`);\n }\n\n const statusText = await page.locator(\"#status-strip\").innerText();\n await context.close();\n\n return {\n baseUrl,\n restored: true,\n sessionId,\n statusText: statusText.replace(/\\s+/g, \" \").trim()\n };\n } finally {\n await browser.close();\n }\n}\n\nasync function cleanupSession(sessionId) {\n if (!sessionId || !sessionIdPattern.test(sessionId)) return;\n await fsp.rm(path.join(rootDir, \"data\", \"sessions\", `${sessionId}.json`), { force: true });\n}\n\nasync function runBrowserSmoke(options = {}) {\n const browserPath = resolveBrowserExecutable({ browserPath: options.browserPath });\n let server = null;\n let result = null;\n let sessionId = null;\n const baseUrl = options.url || null;\n\n try {\n server = baseUrl ? null : await startLocalServer();\n result = await runBrowserJourney({\n baseUrl: baseUrl || server.baseUrl,\n browserPath,\n headed: options.headed,\n onSessionId: (createdSessionId) => {\n sessionId = createdSessionId;\n }\n });\n return result;\n } finally {\n if (result?.sessionId || sessionId) {\n await cleanupSession(result?.sessionId || sessionId);\n }\n if (server) {\n await server.stop();\n }\n }\n}\n\nfunction printHelp() {\n console.log(`Usage: npm run smoke:browser -- [options]\n\nOptions:\n --url <url> Test an already running Qianqiu server instead of starting one.\n --browser <path> Browser executable path. Defaults to BROWSER_EXECUTABLE_PATH or local Chrome/Edge.\n --headed Show the browser window while running.\n --help Show this message.\n`);\n}\n\nif (require.main === module) {\n (async () => {\n const args = parseBrowserSmokeArgs(process.argv);\n if (args.help) {\n printHelp();\n return;\n }\n\n const result = await runBrowserSmoke(args);\n console.log(`Browser smoke passed: ${result.baseUrl}`);\n console.log(`Restored session: ${result.sessionId}`);\n console.log(`Status strip: ${result.statusText}`);\n })().catch((error) => {\n console.error(`Browser smoke failed: ${error.message}`);\n process.exitCode = 1;\n });\n}\n\nmodule.exports = {\n getDefaultBrowserCandidates,\n normalizeBaseUrl,\n parseBrowserSmokeArgs,\n resolveBrowserExecutable,\n runBrowserJourney,\n runBrowserSmoke\n};\n"}}, "src/game/npcRelationshipActions.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595223.3751252, "n": 11722, "h": "bddd16e29ad90bf3", "c": "const {\n NPC_RELATIONSHIP_ACTION_CONFIG,\n NPC_RELATIONSHIP_ACTION_LIMITS,\n NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n NPC_RELATIONSHIP_ACTION_TYPES\n} = require(\"./npcRelationshipActionsConfig\");\nconst {\n buildNpcDetailView,\n ensureNpcRoster,\n getNpcForServer\n} = require(\"./npcRoster\");\n\nconst UNSAFE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes|intent|dossier)|private[_ -]?signal[_ -]?tags|true[_ -]?assets|secret[_ -]?relationships|unrevealed[_ -]?tasks|provider[_ -]?payload|raw[_ -]?(?:provider|audit|table|ledger|prompt|payload|state)|\\b(?:provider|prompt|proposal)\\b|retrieval[_ -]?context|state[_ -]?patch|world[_ -]?state|world_sessions|prompt_retrieval_index|event_archive_index|safe_search_index|safe_search_fts|ai_change_proposals|event_log|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sqlite|data[\\\\/](?:sessions|audit)|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\nconst NPC_RELATIONSHIP_RESOLVER_TRACE_VERSION = \"s88.7-npc-relationship-resolver-trace.v1\";\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = NPC_RELATIONSHIP_ACTION_LIMITS.maxTextLength) {\n if (typeof value !== \"string\") return fallback;\n const text = value.trim().replace(/\\s+/g, \" \");\n if (!text || UNSAFE_TEXT_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 120);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction uniqueCleanList(values = [], limit = 8, maxLength = 80) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction normalizeActionType(value, fallback = \"\") {\n const actionType = cleanText(value, fallback, 48);\n return NPC_RELATIONSHIP_ACTION_TYPES.includes(actionType) ? actionType : fallback;\n}\n\nfunction publicSocialProfile(npc = {}) {\n const source = isPlainObject(npc.socialProfile) ? npc.socialProfile : {};\n return {\n adult: source.adult !== false,\n marriageStatus: cleanText(source.marriageStatus, \"unknown\", 40),\n kinshipBlocked: source.kinshipBlocked === true,\n ritualStatus: cleanText(source.ritualStatus, \"unreviewed\", 40),\n publicNote: cleanText(source.publicNote, \"礼法、身份和亲族意见须由服务器审查。\", 120)\n };\n}\n\nfunction relationshipSnapshot(npc = {}) {\n const relation = isPlainObject(npc.relationship) ? npc.relationship : {};\n return {\n closeness: clampNumber(relation.closeness, -100, 100, 0),\n trust: clampNumber(relation.trust, 0, 100, 0),\n hostility: clampNumber(relation.hostility, 0, 100, 0),\n favorsOwed: clampNumber(relation.favorsOwed, -20, 20, 0),\n labels: uniqueCleanList(relation.labels, 6, 40)\n };\n}\n\nfunction blockersForAction(worldState = {}, npc = {}, actionType = \"\") {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n if (!config) return [\"action_not_supported\"];\n const blockers = [];\n const available = Array.isArray(npc.availableInteractions) ? npc.availableInteractions : [];\n const relation = relationshipSnapshot(npc);\n const social = publicSocialProfile(npc);\n\n if (!available.includes(actionType)) blockers.push(\"npc_action_not_available\");\n if (relation.closeness < config.minimumCloseness) blockers.push(\"relationship_closeness_too_low\");\n if (relation.trust < config.minimumTrust) blockers.push(\"relationship_trust_too_low\");\n if (actionType === \"duel\" && worldState.player?.role === \"emperor\") blockers.push(\"role_cannot_duel_directly\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && !social.adult) blockers.push(\"adult_status_required\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && social.kinshipBlocked) blockers.push(\"kinship_blocks_action\");\n if (actionType === \"marriage\" && ![\"unmarried\", \"unknown\"].includes(social.marriageStatus)) blockers.push(\"marriage_status_unavailable\");\n return blockers.slice(0, NPC_RELATIONSHIP_ACTION_LIMITS.maxBlockers);\n}\n\nfunction buildNpcRelationshipActionEligibilityView(worldState = {}, npcId, options = {}) {\n ensureNpcRoster(worldState);\n const id = cleanId(npcId, \"\");\n const npc = getNpcForServer(worldState, id);\n if (!npc) return null;\n const actions = NPC_RELATIONSHIP_ACTION_TYPES.map((actionType) => {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const blockers = blockersForAction(worldState, npc, actionType);\n return {\n actionType,\n label: config.label,\n requestLabel: config.requestLabel,\n available: blockers.length === 0,\n blockers,\n riskTags: uniqueCleanList(config.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n serverBoundary: \"前端只能提交意图;胜负、伤损、求爱结果、婚姻谱系、资源和关系落账均由服务器裁决。\"\n };\n });\n return {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n npcId: npc.npcId,\n socialProfile: publicSocialProfile(npc),\n relationshipSnapshot: relationshipSnapshot(npc),\n actions,\n safeguards: {\n serverOwnsOutcome: true,\n browserCannotSetSpouseIds: true,\n browserCannotSetWinnerOrInjury: true,\n resourceAndRelationshipDeltasIgnoredFromClient: true,\n privateNpcDossierRedacted: true\n }\n };\n}\n\nfunction actionEligibilityFor(worldState = {}, npcId, actionType) {\n const view = buildNpcRelationshipActionEligibilityView(worldState, npcId);\n const action = view?.actions.find((item) => item.actionType === actionType) || null;\n return { view, action };\n}\n\nfunction applySafeRelationshipImpact(npc = {}, config = {}) {\n if (!npc || !isPlainObject(npc.relationship)) return null;\n const before = relationshipSnapshot(npc);\n npc.relationship.closeness = clampNumber(before.closeness + config.relationshipDelta, -100, 100, before.closeness);\n npc.relationship.trust = clampNumber(before.trust + config.trustDelta, 0, 100, before.trust);\n npc.relationship.hostility = clampNumber(before.hostility + config.hostilityDelta, 0, 100, before.hostility);\n const after = relationshipSnapshot(npc);\n return {\n npcId: npc.npcId,\n npcName: npc.displayName,\n closenessDelta: after.closeness - before.closeness,\n trustDelta: after.trust - before.trust,\n hostilityDelta: after.hostility - before.hostility,\n favorsOwedDelta: 0,\n appliedBy: \"server\"\n };\n}\n\nfunction relationshipDisposition(actionType = \"\", accepted = false) {\n if (!accepted) return \"blocked_by_server_eligibility\";\n if (actionType === \"debate\") return \"public_debate_recorded\";\n if (actionType === \"duel\") return \"duel_review_recorded\";\n if (actionType === \"courtship\") return \"courtship_intent_review\";\n if (actionType === \"marriage\") return \"marriage_ritual_review\";\n return \"relationship_action_recorded\";\n}\n\nfunction buildNpcRelationshipResolverTrace({\n worldState = {},\n npc = {},\n actionType = \"\",\n config = {},\n accepted = false,\n errors = [],\n ignoredClientResultFields = []\n}) {\n const npcId = cleanId(npc.npcId, \"\");\n const turn = clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n return {\n schemaVersion: NPC_RELATIONSHIP_RESOLVER_TRACE_VERSION,\n resolver: \"npc_relationship_action_resolver\",\n publicResolutionRef: cleanId(`npc-relationship-resolution:${npcId || \"npc\"}:${actionType || \"action\"}:${turn}`, \"\"),\n actionType,\n actionLabel: cleanText(config.label, \"交游\", 40),\n status: accepted ? \"server_adjudicated\" : \"server_blocked\",\n disposition: relationshipDisposition(actionType, accepted),\n blockerCount: Array.isArray(errors) ? errors.length : 0,\n ignoredClientResultFields: uniqueCleanList(ignoredClientResultFields, 8, 60),\n publicSourceRefs: uniqueCleanList([\n npcId ? `npcRosterView:${npcId}` : \"\",\n npcId && actionType ? `npcRelationshipActionEligibilityView:${npcId}:${actionType}` : \"\"\n ], 4, 120),\n riskTags: uniqueCleanList(config.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n boundaries: {\n serverOwnsOutcome: true,\n browserCannotSetSpouseIds: true,\n browserCannotSetWinnerOrInjury: true,\n resourceAndRelationshipDeltasIgnoredFromClient: true,\n privateNpcDossierRedacted: true\n }\n };\n}\n\nfunction resolveNpcRelationshipAction(worldState = {}, request = {}, aiResult = {}) {\n ensureNpcRoster(worldState);\n const actionType = normalizeActionType(request.actionType || request.type, \"\");\n const npcId = cleanId(request.npcId, \"\");\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const npc = getNpcForServer(worldState, npcId);\n const errors = [];\n if (!config) errors.push(\"relationship_action_not_supported\");\n if (!npc) errors.push(\"npc_not_found\");\n const eligibility = npc ? actionEligibilityFor(worldState, npc.npcId, actionType) : { view: null, action: null };\n if (eligibility.action && !eligibility.action.available) errors.push(...eligibility.action.blockers);\n\n const accepted = errors.length === 0;\n const relationshipImpactView = accepted ? applySafeRelationshipImpact(npc, config) : null;\n const dialogueText = cleanText(aiResult.dialogueText, \"\", 260);\n const outcomeSummary = accepted\n ? config.outcomeSummary\n : \"服务器挡下此项交游动作;前端提交的胜负、婚姻、资源或关系结果均未采纳。\";\n const ignoredClientResultFields = uniqueCleanList(\n Object.keys(isPlainObject(request) ? request : {})\n .filter((key) => /winner|injury|spouse|relationshipDelta|resourceDelta|acceptedMarriage|statePatch/i.test(key)),\n 8,\n 60\n );\n const resolverTrace = buildNpcRelationshipResolverTrace({\n worldState,\n npc: npc || { npcId },\n actionType,\n config: config || {},\n accepted,\n errors,\n ignoredClientResultFields\n });\n const result = {\n ok: accepted,\n errors,\n resolutionView: {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n actionType,\n actionLabel: config?.label || \"交游\",\n serverStatus: accepted ? \"server_adjudicated\" : \"server_blocked\",\n outcomeSummary,\n publicNarrative: dialogueText || outcomeSummary,\n resolverTrace,\n riskTags: uniqueCleanList(config?.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n eligibilityView: eligibility.view,\n relationshipImpactView: relationshipImpactView || {\n npcId,\n appliedBy: \"server\",\n note: accepted ? \"无可见关系项可调整。\" : \"动作被服务器挡下。\"\n },\n resourceImpactView: {\n applied: false,\n reason: \"论道、切磋、求爱和婚姻扩展位不会让前端或 AI 直接扣银、转物或写资源账本。\"\n },\n worldPeopleImpactView: {\n applied: false,\n spouseIdsWritten: false,\n reason: actionType === \"marriage\"\n ? \"本阶段只登记议婚审查,不写 worldPeople.npcs[].family.spouseIds。\"\n : \"本动作不写人物谱系。\"\n },\n ignoredClientResultFields\n }\n };\n return result;\n}\n\nfunction attachRelationshipActionEligibilityToDetail(worldState = {}, detailView = null) {\n if (!detailView?.npcId) return detailView;\n return {\n ...detailView,\n relationshipActionEligibilityView: buildNpcRelationshipActionEligibilityView(worldState, detailView.npcId)\n };\n}\n\nmodule.exports = {\n attachRelationshipActionEligibilityToDetail,\n buildNpcRelationshipActionEligibilityView,\n resolveNpcRelationshipAction,\n NPC_RELATIONSHIP_ACTION_TYPES\n};\n"}, "875a0147343d795e739682b7da27e988166cf8b6": {"t": 1785753933.5096893, "n": 11722, "h": "bddd16e29ad90bf3", "c": "const {\n NPC_RELATIONSHIP_ACTION_CONFIG,\n NPC_RELATIONSHIP_ACTION_LIMITS,\n NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n NPC_RELATIONSHIP_ACTION_TYPES\n} = require(\"./npcRelationshipActionsConfig\");\nconst {\n buildNpcDetailView,\n ensureNpcRoster,\n getNpcForServer\n} = require(\"./npcRoster\");\n\nconst UNSAFE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes|intent|dossier)|private[_ -]?signal[_ -]?tags|true[_ -]?assets|secret[_ -]?relationships|unrevealed[_ -]?tasks|provider[_ -]?payload|raw[_ -]?(?:provider|audit|table|ledger|prompt|payload|state)|\\b(?:provider|prompt|proposal)\\b|retrieval[_ -]?context|state[_ -]?patch|world[_ -]?state|world_sessions|prompt_retrieval_index|event_archive_index|safe_search_index|safe_search_fts|ai_change_proposals|event_log|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sqlite|data[\\\\/](?:sessions|audit)|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\nconst NPC_RELATIONSHIP_RESOLVER_TRACE_VERSION = \"s88.7-npc-relationship-resolver-trace.v1\";\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = NPC_RELATIONSHIP_ACTION_LIMITS.maxTextLength) {\n if (typeof value !== \"string\") return fallback;\n const text = value.trim().replace(/\\s+/g, \" \");\n if (!text || UNSAFE_TEXT_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 120);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction uniqueCleanList(values = [], limit = 8, maxLength = 80) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction normalizeActionType(value, fallback = \"\") {\n const actionType = cleanText(value, fallback, 48);\n return NPC_RELATIONSHIP_ACTION_TYPES.includes(actionType) ? actionType : fallback;\n}\n\nfunction publicSocialProfile(npc = {}) {\n const source = isPlainObject(npc.socialProfile) ? npc.socialProfile : {};\n return {\n adult: source.adult !== false,\n marriageStatus: cleanText(source.marriageStatus, \"unknown\", 40),\n kinshipBlocked: source.kinshipBlocked === true,\n ritualStatus: cleanText(source.ritualStatus, \"unreviewed\", 40),\n publicNote: cleanText(source.publicNote, \"礼法、身份和亲族意见须由服务器审查。\", 120)\n };\n}\n\nfunction relationshipSnapshot(npc = {}) {\n const relation = isPlainObject(npc.relationship) ? npc.relationship : {};\n return {\n closeness: clampNumber(relation.closeness, -100, 100, 0),\n trust: clampNumber(relation.trust, 0, 100, 0),\n hostility: clampNumber(relation.hostility, 0, 100, 0),\n favorsOwed: clampNumber(relation.favorsOwed, -20, 20, 0),\n labels: uniqueCleanList(relation.labels, 6, 40)\n };\n}\n\nfunction blockersForAction(worldState = {}, npc = {}, actionType = \"\") {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n if (!config) return [\"action_not_supported\"];\n const blockers = [];\n const available = Array.isArray(npc.availableInteractions) ? npc.availableInteractions : [];\n const relation = relationshipSnapshot(npc);\n const social = publicSocialProfile(npc);\n\n if (!available.includes(actionType)) blockers.push(\"npc_action_not_available\");\n if (relation.closeness < config.minimumCloseness) blockers.push(\"relationship_closeness_too_low\");\n if (relation.trust < config.minimumTrust) blockers.push(\"relationship_trust_too_low\");\n if (actionType === \"duel\" && worldState.player?.role === \"emperor\") blockers.push(\"role_cannot_duel_directly\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && !social.adult) blockers.push(\"adult_status_required\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && social.kinshipBlocked) blockers.push(\"kinship_blocks_action\");\n if (actionType === \"marriage\" && ![\"unmarried\", \"unknown\"].includes(social.marriageStatus)) blockers.push(\"marriage_status_unavailable\");\n return blockers.slice(0, NPC_RELATIONSHIP_ACTION_LIMITS.maxBlockers);\n}\n\nfunction buildNpcRelationshipActionEligibilityView(worldState = {}, npcId, options = {}) {\n ensureNpcRoster(worldState);\n const id = cleanId(npcId, \"\");\n const npc = getNpcForServer(worldState, id);\n if (!npc) return null;\n const actions = NPC_RELATIONSHIP_ACTION_TYPES.map((actionType) => {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const blockers = blockersForAction(worldState, npc, actionType);\n return {\n actionType,\n label: config.label,\n requestLabel: config.requestLabel,\n available: blockers.length === 0,\n blockers,\n riskTags: uniqueCleanList(config.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n serverBoundary: \"前端只能提交意图;胜负、伤损、求爱结果、婚姻谱系、资源和关系落账均由服务器裁决。\"\n };\n });\n return {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n npcId: npc.npcId,\n socialProfile: publicSocialProfile(npc),\n relationshipSnapshot: relationshipSnapshot(npc),\n actions,\n safeguards: {\n serverOwnsOutcome: true,\n browserCannotSetSpouseIds: true,\n browserCannotSetWinnerOrInjury: true,\n resourceAndRelationshipDeltasIgnoredFromClient: true,\n privateNpcDossierRedacted: true\n }\n };\n}\n\nfunction actionEligibilityFor(worldState = {}, npcId, actionType) {\n const view = buildNpcRelationshipActionEligibilityView(worldState, npcId);\n const action = view?.actions.find((item) => item.actionType === actionType) || null;\n return { view, action };\n}\n\nfunction applySafeRelationshipImpact(npc = {}, config = {}) {\n if (!npc || !isPlainObject(npc.relationship)) return null;\n const before = relationshipSnapshot(npc);\n npc.relationship.closeness = clampNumber(before.closeness + config.relationshipDelta, -100, 100, before.closeness);\n npc.relationship.trust = clampNumber(before.trust + config.trustDelta, 0, 100, before.trust);\n npc.relationship.hostility = clampNumber(before.hostility + config.hostilityDelta, 0, 100, before.hostility);\n const after = relationshipSnapshot(npc);\n return {\n npcId: npc.npcId,\n npcName: npc.displayName,\n closenessDelta: after.closeness - before.closeness,\n trustDelta: after.trust - before.trust,\n hostilityDelta: after.hostility - before.hostility,\n favorsOwedDelta: 0,\n appliedBy: \"server\"\n };\n}\n\nfunction relationshipDisposition(actionType = \"\", accepted = false) {\n if (!accepted) return \"blocked_by_server_eligibility\";\n if (actionType === \"debate\") return \"public_debate_recorded\";\n if (actionType === \"duel\") return \"duel_review_recorded\";\n if (actionType === \"courtship\") return \"courtship_intent_review\";\n if (actionType === \"marriage\") return \"marriage_ritual_review\";\n return \"relationship_action_recorded\";\n}\n\nfunction buildNpcRelationshipResolverTrace({\n worldState = {},\n npc = {},\n actionType = \"\",\n config = {},\n accepted = false,\n errors = [],\n ignoredClientResultFields = []\n}) {\n const npcId = cleanId(npc.npcId, \"\");\n const turn = clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n return {\n schemaVersion: NPC_RELATIONSHIP_RESOLVER_TRACE_VERSION,\n resolver: \"npc_relationship_action_resolver\",\n publicResolutionRef: cleanId(`npc-relationship-resolution:${npcId || \"npc\"}:${actionType || \"action\"}:${turn}`, \"\"),\n actionType,\n actionLabel: cleanText(config.label, \"交游\", 40),\n status: accepted ? \"server_adjudicated\" : \"server_blocked\",\n disposition: relationshipDisposition(actionType, accepted),\n blockerCount: Array.isArray(errors) ? errors.length : 0,\n ignoredClientResultFields: uniqueCleanList(ignoredClientResultFields, 8, 60),\n publicSourceRefs: uniqueCleanList([\n npcId ? `npcRosterView:${npcId}` : \"\",\n npcId && actionType ? `npcRelationshipActionEligibilityView:${npcId}:${actionType}` : \"\"\n ], 4, 120),\n riskTags: uniqueCleanList(config.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n boundaries: {\n serverOwnsOutcome: true,\n browserCannotSetSpouseIds: true,\n browserCannotSetWinnerOrInjury: true,\n resourceAndRelationshipDeltasIgnoredFromClient: true,\n privateNpcDossierRedacted: true\n }\n };\n}\n\nfunction resolveNpcRelationshipAction(worldState = {}, request = {}, aiResult = {}) {\n ensureNpcRoster(worldState);\n const actionType = normalizeActionType(request.actionType || request.type, \"\");\n const npcId = cleanId(request.npcId, \"\");\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const npc = getNpcForServer(worldState, npcId);\n const errors = [];\n if (!config) errors.push(\"relationship_action_not_supported\");\n if (!npc) errors.push(\"npc_not_found\");\n const eligibility = npc ? actionEligibilityFor(worldState, npc.npcId, actionType) : { view: null, action: null };\n if (eligibility.action && !eligibility.action.available) errors.push(...eligibility.action.blockers);\n\n const accepted = errors.length === 0;\n const relationshipImpactView = accepted ? applySafeRelationshipImpact(npc, config) : null;\n const dialogueText = cleanText(aiResult.dialogueText, \"\", 260);\n const outcomeSummary = accepted\n ? config.outcomeSummary\n : \"服务器挡下此项交游动作;前端提交的胜负、婚姻、资源或关系结果均未采纳。\";\n const ignoredClientResultFields = uniqueCleanList(\n Object.keys(isPlainObject(request) ? request : {})\n .filter((key) => /winner|injury|spouse|relationshipDelta|resourceDelta|acceptedMarriage|statePatch/i.test(key)),\n 8,\n 60\n );\n const resolverTrace = buildNpcRelationshipResolverTrace({\n worldState,\n npc: npc || { npcId },\n actionType,\n config: config || {},\n accepted,\n errors,\n ignoredClientResultFields\n });\n const result = {\n ok: accepted,\n errors,\n resolutionView: {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n actionType,\n actionLabel: config?.label || \"交游\",\n serverStatus: accepted ? \"server_adjudicated\" : \"server_blocked\",\n outcomeSummary,\n publicNarrative: dialogueText || outcomeSummary,\n resolverTrace,\n riskTags: uniqueCleanList(config?.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n eligibilityView: eligibility.view,\n relationshipImpactView: relationshipImpactView || {\n npcId,\n appliedBy: \"server\",\n note: accepted ? \"无可见关系项可调整。\" : \"动作被服务器挡下。\"\n },\n resourceImpactView: {\n applied: false,\n reason: \"论道、切磋、求爱和婚姻扩展位不会让前端或 AI 直接扣银、转物或写资源账本。\"\n },\n worldPeopleImpactView: {\n applied: false,\n spouseIdsWritten: false,\n reason: actionType === \"marriage\"\n ? \"本阶段只登记议婚审查,不写 worldPeople.npcs[].family.spouseIds。\"\n : \"本动作不写人物谱系。\"\n },\n ignoredClientResultFields\n }\n };\n return result;\n}\n\nfunction attachRelationshipActionEligibilityToDetail(worldState = {}, detailView = null) {\n if (!detailView?.npcId) return detailView;\n return {\n ...detailView,\n relationshipActionEligibilityView: buildNpcRelationshipActionEligibilityView(worldState, detailView.npcId)\n };\n}\n\nmodule.exports = {\n attachRelationshipActionEligibilityToDetail,\n buildNpcRelationshipActionEligibilityView,\n resolveNpcRelationshipAction,\n NPC_RELATIONSHIP_ACTION_TYPES\n};\n"}, "f984ebc59a2dfca443c3261402dc6032cfd9380e": {"t": 1785753934.8189027, "n": 9415, "h": "3493cc0e0d55399c", "c": "const {\n NPC_RELATIONSHIP_ACTION_CONFIG,\n NPC_RELATIONSHIP_ACTION_LIMITS,\n NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n NPC_RELATIONSHIP_ACTION_TYPES\n} = require(\"./npcRelationshipActionsConfig\");\nconst {\n buildNpcDetailView,\n ensureNpcRoster,\n getNpcForServer\n} = require(\"./npcRoster\");\n\nconst UNSAFE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|hiddenDossier|privateSignalTags|trueAssets|secretRelationships|unrevealedTasks|raw[_ -]?(?:provider|audit|table|ledger|prompt|payload|state)|\\b(?:provider|prompt|proposal)\\b|retrievalContext|statePatch|worldState|world_sessions|prompt_retrieval_index|event_archive_index|safe_search_index|ai_change_proposals|event_log|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sqlite|data[\\\\/](?:sessions|audit)|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = NPC_RELATIONSHIP_ACTION_LIMITS.maxTextLength) {\n if (typeof value !== \"string\") return fallback;\n const text = value.trim().replace(/\\s+/g, \" \");\n if (!text || UNSAFE_TEXT_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 120);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction uniqueCleanList(values = [], limit = 8, maxLength = 80) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction normalizeActionType(value, fallback = \"\") {\n const actionType = cleanText(value, fallback, 48);\n return NPC_RELATIONSHIP_ACTION_TYPES.includes(actionType) ? actionType : fallback;\n}\n\nfunction publicSocialProfile(npc = {}) {\n const source = isPlainObject(npc.socialProfile) ? npc.socialProfile : {};\n return {\n adult: source.adult !== false,\n marriageStatus: cleanText(source.marriageStatus, \"unknown\", 40),\n kinshipBlocked: source.kinshipBlocked === true,\n ritualStatus: cleanText(source.ritualStatus, \"unreviewed\", 40),\n publicNote: cleanText(source.publicNote, \"礼法、身份和亲族意见须由服务器审查。\", 120)\n };\n}\n\nfunction relationshipSnapshot(npc = {}) {\n const relation = isPlainObject(npc.relationship) ? npc.relationship : {};\n return {\n closeness: clampNumber(relation.closeness, -100, 100, 0),\n trust: clampNumber(relation.trust, 0, 100, 0),\n hostility: clampNumber(relation.hostility, 0, 100, 0),\n favorsOwed: clampNumber(relation.favorsOwed, -20, 20, 0),\n labels: uniqueCleanList(relation.labels, 6, 40)\n };\n}\n\nfunction blockersForAction(worldState = {}, npc = {}, actionType = \"\") {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n if (!config) return [\"action_not_supported\"];\n const blockers = [];\n const available = Array.isArray(npc.availableInteractions) ? npc.availableInteractions : [];\n const relation = relationshipSnapshot(npc);\n const social = publicSocialProfile(npc);\n\n if (!available.includes(actionType)) blockers.push(\"npc_action_not_available\");\n if (relation.closeness < config.minimumCloseness) blockers.push(\"relationship_closeness_too_low\");\n if (relation.trust < config.minimumTrust) blockers.push(\"relationship_trust_too_low\");\n if (actionType === \"duel\" && worldState.player?.role === \"emperor\") blockers.push(\"role_cannot_duel_directly\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && !social.adult) blockers.push(\"adult_status_required\");\n if ((actionType === \"courtship\" || actionType === \"marriage\") && social.kinshipBlocked) blockers.push(\"kinship_blocks_action\");\n if (actionType === \"marriage\" && ![\"unmarried\", \"unknown\"].includes(social.marriageStatus)) blockers.push(\"marriage_status_unavailable\");\n return blockers.slice(0, NPC_RELATIONSHIP_ACTION_LIMITS.maxBlockers);\n}\n\nfunction buildNpcRelationshipActionEligibilityView(worldState = {}, npcId, options = {}) {\n ensureNpcRoster(worldState);\n const id = cleanId(npcId, \"\");\n const npc = getNpcForServer(worldState, id);\n if (!npc) return null;\n const actions = NPC_RELATIONSHIP_ACTION_TYPES.map((actionType) => {\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const blockers = blockersForAction(worldState, npc, actionType);\n return {\n actionType,\n label: config.label,\n requestLabel: config.requestLabel,\n available: blockers.length === 0,\n blockers,\n riskTags: uniqueCleanList(config.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n serverBoundary: \"前端只能提交意图;胜负、伤损、求爱结果、婚姻谱系、资源和关系落账均由服务器裁决。\"\n };\n });\n return {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n npcId: npc.npcId,\n socialProfile: publicSocialProfile(npc),\n relationshipSnapshot: relationshipSnapshot(npc),\n actions,\n safeguards: {\n serverOwnsOutcome: true,\n browserCannotSetSpouseIds: true,\n browserCannotSetWinnerOrInjury: true,\n resourceAndRelationshipDeltasIgnoredFromClient: true,\n privateNpcDossierRedacted: true\n }\n };\n}\n\nfunction actionEligibilityFor(worldState = {}, npcId, actionType) {\n const view = buildNpcRelationshipActionEligibilityView(worldState, npcId);\n const action = view?.actions.find((item) => item.actionType === actionType) || null;\n return { view, action };\n}\n\nfunction applySafeRelationshipImpact(npc = {}, config = {}) {\n if (!npc || !isPlainObject(npc.relationship)) return null;\n const before = relationshipSnapshot(npc);\n npc.relationship.closeness = clampNumber(before.closeness + config.relationshipDelta, -100, 100, before.closeness);\n npc.relationship.trust = clampNumber(before.trust + config.trustDelta, 0, 100, before.trust);\n npc.relationship.hostility = clampNumber(before.hostility + config.hostilityDelta, 0, 100, before.hostility);\n const after = relationshipSnapshot(npc);\n return {\n npcId: npc.npcId,\n npcName: npc.displayName,\n closenessDelta: after.closeness - before.closeness,\n trustDelta: after.trust - before.trust,\n hostilityDelta: after.hostility - before.hostility,\n favorsOwedDelta: 0,\n appliedBy: \"server\"\n };\n}\n\nfunction resolveNpcRelationshipAction(worldState = {}, request = {}, aiResult = {}) {\n ensureNpcRoster(worldState);\n const actionType = normalizeActionType(request.actionType || request.type, \"\");\n const npcId = cleanId(request.npcId, \"\");\n const config = NPC_RELATIONSHIP_ACTION_CONFIG[actionType];\n const npc = getNpcForServer(worldState, npcId);\n const errors = [];\n if (!config) errors.push(\"relationship_action_not_supported\");\n if (!npc) errors.push(\"npc_not_found\");\n const eligibility = npc ? actionEligibilityFor(worldState, npc.npcId, actionType) : { view: null, action: null };\n if (eligibility.action && !eligibility.action.available) errors.push(...eligibility.action.blockers);\n\n const accepted = errors.length === 0;\n const relationshipImpactView = accepted ? applySafeRelationshipImpact(npc, config) : null;\n const dialogueText = cleanText(aiResult.dialogueText, \"\", 260);\n const outcomeSummary = accepted\n ? config.outcomeSummary\n : \"服务器挡下此项交游动作;前端提交的胜负、婚姻、资源或关系结果均未采纳。\";\n const result = {\n ok: accepted,\n errors,\n resolutionView: {\n schemaVersion: NPC_RELATIONSHIP_ACTION_SCHEMA_VERSION,\n actionType,\n actionLabel: config?.label || \"交游\",\n serverStatus: accepted ? \"server_adjudicated\" : \"server_blocked\",\n outcomeSummary,\n publicNarrative: dialogueText || outcomeSummary,\n riskTags: uniqueCleanList(config?.riskTags, NPC_RELATIONSHIP_ACTION_LIMITS.maxRiskTags, 40),\n eligibilityView: eligibility.view,\n relationshipImpactView: relationshipImpactView || {\n npcId,\n appliedBy: \"server\",\n note: accepted ? \"无可见关系项可调整。\" : \"动作被服务器挡下。\"\n },\n resourceImpactView: {\n applied: false,\n reason: \"论道、切磋、求爱和婚姻扩展位不会让前端或 AI 直接扣银、转物或写资源账本。\"\n },\n worldPeopleImpactView: {\n applied: false,\n spouseIdsWritten: false,\n reason: actionType === \"marriage\"\n ? \"本阶段只登记议婚审查,不写 worldPeople.npcs[].family.spouseIds。\"\n : \"本动作不写人物谱系。\"\n },\n ignoredClientResultFields: Object.keys(isPlainObject(request) ? request : {})\n .filter((key) => /winner|injury|spouse|relationshipDelta|resourceDelta|acceptedMarriage|statePatch/i.test(key))\n .slice(0, 8)\n }\n };\n return result;\n}\n\nfunction attachRelationshipActionEligibilityToDetail(worldState = {}, detailView = null) {\n if (!detailView?.npcId) return detailView;\n return {\n ...detailView,\n relationshipActionEligibilityView: buildNpcRelationshipActionEligibilityView(worldState, detailView.npcId)\n };\n}\n\nmodule.exports = {\n attachRelationshipActionEligibilityToDetail,\n buildNpcRelationshipActionEligibilityView,\n resolveNpcRelationshipAction,\n NPC_RELATIONSHIP_ACTION_TYPES\n};\n"}}, "docs/AI_TOOL_PROTOCOL_CONTRACT.md": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595227.4106326, "n": 10355, "h": "e994671cdce523ea", "c": "# 《千秋》S70.1 AI 工具协议契约\n\n本契约固定 `game_ai_tools` 的模型可见工具 envelope、proposal/result/request-adjudication schema、provider 兼容策略、审计和失败降级。核心原则只有一句:**模型请求工具,服务器执行工具;tool call 不是已经发生的世界事实。**\n\n技术资料基线于 2026-05-12 核验:\n\n- [OpenAI Function Calling](https://developers.openai.com/api/docs/guides/function-calling):function/tool calling 用 JSON schema 定义工具,应用代码执行工具。\n- [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs):strict schema 要求对象 `additionalProperties: false`,JSON mode 不等于 schema adherence。\n- [Anthropic Define tools](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools):工具包含 `name`、`description`、`input_schema`,应用负责执行 client tools。\n- [DeepSeek Function Calling](https://api-docs.deepseek.com/guides/function_calling/):OpenAI-compatible function calling,模型本身不执行函数;strict mode 需单独验证。\n- [MCP Architecture](https://modelcontextprotocol.io/docs/learn/architecture):工具经 `tools/list` 发现,经 `tools/call` 执行。\n- [Xiaomi MiMo-V2.5-Pro](https://mimo.xiaomi.com/mimo-v2-5-pro/):官方强调长程 agentic / tool-use 能力;本项目必须用真实 smoke 固定其 OpenAI-compatible 返回形状。\n\n这些资料只决定接口兼容方向,不授予模型额外权力。\n\n## 1. 工具分层\n\nS70 工具分三类:\n\n| 类型 | 模型能做什么 | 服务器做什么 | 例子 |\n| --- | --- | --- | --- |\n| `read` | 请求读取 actor 可见摘要。 | 按身份、辖区、关系、情报可信度和 budget 返回 capped view。 | `world.read_visible_context`、`office.read_docket`、`memory.read_actor_memory`。 |\n| `proposal` | 提交事件、关系、政务、刑名、军令、外交、记忆、地图等候选建议。 | 校验权限、证据、冷却、风险和 schema,决定 accepted/rejected/pending。 | `event.propose_incident`、`city.propose_policy`、`relationship.propose_delta`。 |\n| `request_adjudication` | 请求服务器裁决高影响动作。 | 运行领域 resolver,写公开结果、隐藏后果引用和审计。 | `ruler.request_edict_adjudication`、`judicial.request_case_adjudication`、`office.request_appointment_adjudication`。 |\n\n`server.*` 只能是内部 resolver bridge 或 audit label,不能出现在模型可见 tool list。后期如果包装内部 MCP server,`tools/list` 也只列 actor 可见的 `read/proposal/request_adjudication` 工具。\n\n## 2. Tool Envelope\n\n每个模型可见工具必须包含:\n\n| 字段 | 要求 |\n| --- | --- |\n| `name` | 内部稳定名,使用领域命名空间,如 `world.read_visible_context`;不得以 `server.` 开头。 |\n| `description` | 给模型看的说明,写清何时使用、何时不要使用、返回什么、不返回什么。 |\n| `inputSchema` | JSON schema,模型可见参数;对象默认 `additionalProperties: false`,为跨 provider portability,首批要求 properties 全列入 `required`。 |\n| `permission` | actor tier、actor type、工具组、readScope、proposalScope、visibilityBoundary、forbiddenScopes、辖区/证据要求。 |\n| `resolver` | 服务器裁决入口;必须 `serverOwned: true`。 |\n| `audit` | event type、参数摘要字段、redact 字段、是否记录 rejected、玩家可见 projection。 |\n| `cooldown` | `none/actor/scene/jurisdiction/world` 与 turn 数,防止反复滥用。 |\n| `mockFallback` | Mock/no-key 行为:deterministic、reject 或 pending。 |\n\n可选字段:`riskTags`、`providerCompatibility`、`toolGroups`、`transactionBoundary`。\n\n运行时代码入口:`src/ai/toolSchemas.js`。\n\n## 3. Provider 可见转换\n\n内部工具名可以用点分命名;OpenAI-compatible chat completions 和 Anthropic 工具名通常更偏向 `[A-Za-z0-9_-]`。因此 provider 适配层必须稳定转换:\n\n```text\nworld.read_visible_context -> world_read_visible_context\njudicial.request_case_adjudication -> judicial_request_case_adjudication\n```\n\n转换规则:\n\n- provider-visible name 只用于模型接口;内部审计和 resolver 仍记录原始 `name`。\n- 转换不得把 `server.*` 暴露给模型。\n- provider 工具参数只包含 `inputSchema`;`permission`、`resolver`、`audit`、`cooldown`、`mockFallback` 保留在服务器侧。\n- 工具结果回填给模型时只放 `toolResult.publicResult`、公开拒绝原因和必要 `auditRef`,不放 private refs 内容。\n\n## 4. Strict Schema 规则\n\nS70.1 首批工具参数 schema 采用保守 strict 子集:\n\n- 顶层和嵌套 object 都必须 `additionalProperties: false`。\n- 对象 properties 必须全部列入 `required`;需要可选语义时先用空字符串、空数组、`null` union 或后续明确兼容策略。\n- 不使用通用 `object additionalProperties: true` 作为模型参数入口。\n- 不把 raw SQL、raw table name、raw `worldState` patch、raw audit insert、local path、provider config、hidden note/id、完整 prompt 放进参数。\n- provider 原始返回必须先解析并用 schema 校验,再交给权限检查和 resolver。\n\nOpenAI Structured Outputs 对 strict schema 支持较强;DeepSeek strict mode、MiMo OpenAI-compatible 工具形状和 Anthropic tool schema 仍要分别 smoke,不做“兼容接口等于同等严格”的假设。\n\n## 5. Proposal Schema\n\nproposal 是模型的“建议”,不是事实。统一 envelope:\n\n```text\nproposalId\ntoolName\nactorRef\nintent\narguments\nvisibility\nconfidence\nevidenceRefs\nboundaryStatement\nrequestedFollowUp\n```\n\n要求:\n\n- `actorRef` 至少含 `actorId`、`actorType`、`authorityTier`。\n- `visibility` 只能是 `player_visible`、`actor_visible` 或 `server_private`。\n- `confidence` 仅表示模型自信,不决定服务器采纳。\n- `boundaryStatement` 必须承认“服务器裁决后才发生”。\n- `arguments` 再由具体工具 `inputSchema` 校验。\n\n服务器可把 proposal 转为 `accepted/rejected/pending`,并记录拒绝原因;未采纳 proposal 不得进入玩家事实账本。\n\n## 6. Request-adjudication Schema\n\n高影响工具必须走 request-adjudication:\n\n```text\nrequestId\ndomain\ntoolName\nactorRef\nrequestedAction\narguments\nauthorityBasis\nevidenceRefs\nriskDisclosure\nvisibleToPlayer\n```\n\n适用领域:\n\n- `office` / `career`:任免、升迁、赏罚、弹劾。\n- `judicial`:判案、拘捕、翻供、重罪处分。\n- `military`:出兵、固守、会战、调粮。\n- `diplomacy`:互市、和议、扣使、宣战。\n- `exam`:评卷复核、荐卷、授官倾向。\n- `event` / `time` / `memory` / `map`:事件成案、跳时、记忆、移动或地图事件。\n\n返回只能是 `accepted`、`rejected`、`pending` 或 `failed`,并携带公开结果、拒绝理由、成本、follow-up hooks 和 audit ref。\n\n## 7. Tool Result Schema\n\n工具结果是服务器输出给模型的下一步上下文:\n\n```text\nstatus\ntoolName\nactorRef\npublicResult.summary\npublicResult.visibleChanges\nprivateResultRefs\nappliedEventIds\nrejectionReasons\ncounterCosts\nfollowUpHooks\nauditRef\nmodelFollowUpHint\n```\n\n边界:\n\n- `privateResultRefs` 只是服务器内部引用,不能展开给模型。\n- `appliedEventIds` 只列安全公开 id;hidden event id 需另做 redaction。\n- `rejected` 和 `pending` 必须被模型叙述为阻力、待议、证据不足或上级未决,不得说成已经发生。\n- `failed` 不能写 session;应降级到 Mock/heuristic 或给玩家可读错误。\n\n## 8. 审计与冷却\n\n每次工具调用至少记录:\n\n- actor 摘要:`actorId`、`actorType`、authority tier、辖区。\n- tool name 与 provider-visible name。\n- 参数脱敏摘要。\n- 权限检查、cooldown 检查和 schema 检查结果。\n- resolver status、公开结果、拒绝原因、成本和 follow-up hooks。\n- provider/model/task type、耗时、重试和失败降级摘要。\n\n不得记录或展示:完整 prompt、密钥、`.env`、本地路径、raw provider proposal、hidden ledger、raw audit payload、raw SQLite table row。\n\n## 9. Provider 兼容矩阵\n\n| Provider | S70.1 策略 |\n| --- | --- |\n| Mock | deterministic 工具结果与拒绝样例,保证 CI 和 no-key 回归。 |\n| MiMo-V2.5-Pro | 主力真实 smoke;直接走 `chat/completions` + `tools` + `tool_choice`,记录 `choices[0].message.tool_calls`、工具参数编码、工具结果回填和后续 streaming 形状。 |\n| OpenAI | 后续用 Responses/Chat 工具形状适配;strict schema 与 function calling 分开测试。 |\n| DeepSeek | OpenAI-compatible function calling 可用但 strict mode 需单独验证;评卷 provider 仍不能获得工具写权。 |\n| Anthropic | 适配 `tools` / `input_schema` 与 tool result blocks;工具描述需更高信号。 |\n| MCP | S70 前期不启动外部 MCP server;若后期包装内部 MCP,只暴露自家工具层,不接通用外部工具。 |\n\n`npm run smoke:provider:tools` 当前先固定 MiMo forced tool call 与 tool result roundtrip;`--full`、`--stream` 留给后续真实形状稳定后扩展。无 `MIMO_API_KEY` 时明确 skip;`MIMO_REQUIRED=1` 或 `--required` 时缺 key fail。\n\n## 10. 禁止项\n\n工具、MCP server、provider adapter 或 prompt 都不得暴露:\n\n- 通用 SQL、raw table read/write、raw session patch、raw audit insert。\n- `worldState` 任意 patch、`statePatch.worldState`、`write_anything`、`execute_code`、浏览器控制、外部 web search。\n- hidden notes、hidden intent、未公开关系、未公开任所、弥封映射、保结密注、密档链、隐藏情报真值。\n- provider key、base URL、`.env`、本地路径、完整 prompt。\n- `server.*` 作为模型可见工具名。\n\n多个模型、critic 或 safety 同意,也不能绕过 schema、权限、cooldown、resolver、clamp、visibility filter、科举晋级、官职任免、战争/外交裁决或持久化事务。\n\n## 11. S70.1 验收命令\n\n```bash\nnode --check src/ai/toolSchemas.js\nnode --check scripts/providerToolSmoke.js\nnode --test test/aiToolProtocolContract.test.js test/providerToolSmokeScript.test.js test/aiSchemas.test.js test/prompts.test.js\nnpm run check:docs-governance\nnpm run smoke:provider:tools\n```\n\n有真实 MiMo key 且要强制验收:\n\n```bash\nMIMO_REQUIRED=1 npm run smoke:provider:tools\n```\n\n在 Windows PowerShell 中可用:\n\n```powershell\n$env:MIMO_REQUIRED='1'; npm run smoke:provider:tools\n```\n\n## 12. 后续接点\n\n- S70.2:把 `permission` 映射到 `aiActorProfile`、authority tier、actor tool allowlist 和角色视野。\n- S70.3:实现 `game_ai_tools` registry、runner、resolver bridge、audit hook 和 Mock runner。\n- S70.6:已落地 `event.propose_incident` 与 `event.request_incident_adjudication` 首批事件工具。事件工具使用 strict input schema,要求 `incidentKind`、`publicSummary`、`sourcePressureRefs`、`visibility`、`confidence`、`severity`、`cooldownKey`、`affectedRefs`、空 `privateResultRefs` 和 `riskTags`;默认 registry 会按 actor 权限过滤,resolver 只返回 `pending` / `rejected`,不写状态、SQLite、事件档案或成案审计。\n- S70.7:已落地 `judicial.propose_case_resolution`、`city.propose_policy`、`military.propose_order`、`diplomacy.propose_move`、`exam.request_ranking_adjudication`、`office.request_appointment_adjudication`、`career.propose_reward_or_promotion` 和 `career.request_discipline_adjudication`。这些工具使用 strict input schema,要求领域动作、`publicSummary`、`evidenceRefs`、`targetRefs`、`jurisdictionRef`、`visibility`、`confidence`、`riskLevel`、`cooldownKey`、`expectedBenefits`、`counterCosts`、`riskDisclosure`、空 `privateResultRefs` 和 `riskTags`;默认 registry 会按 actor 权限过滤,resolver 只返回 `pending` / `rejected`,不写状态、SQLite、事件档案、官职、榜单、判决、军令、战和、赏罚或成案审计。\n- S71.5:`city.propose_policy.policyKind` 已扩展为财政与城市政策全集(赈济、平粜、征粮、修堤、清丈、减免、追赃、盐漕、徭役、市场、治安)。默认模型可见 runner 仍只记录 pending/rejected proposal;服务器内部可把该 proposal 交给 `cityPolicyResolver` 裁决。`server.adjudicate_policy` 仍不是模型可见工具名、provider function name、MCP `tools/list` 条目或玩家可伪造 call。\n- S71.6:`judicial.propose_case_resolution.caseAction` 已扩展为刑名动作全集(受理、传唤、查证、调解、罚银、羁押、驳回、判决、申详、移交、缓审)。默认模型可见 runner 仍只记录 pending/rejected proposal;服务器内部可把该 proposal 交给 `judicialCaseResolver` 裁决。`server.resolve_case` 仍不是模型可见工具名、provider function name、MCP `tools/list` 条目或玩家可伪造 call;公开 docket/event 只显示可见证据摘要,不公开 evidence ref、raw proposal 或 hidden 证据。\n- S71.7:`military.propose_order.orderKind` 与 `diplomacy.propose_move.moveKind` 已改为从 `militaryDiplomacyResolverConfig` 动作全集生成,覆盖侦察、固守、练兵、调粮、动员、出击、会战、大会战、撤军、遣使、互市、和议、朝贡、边境警告、宣战 request、会盟和扣使。默认模型可见 runner 仍只记录 pending/rejected proposal;服务器内部可把该 proposal 交给 `militaryDiplomacyResolver` 裁决。`server.resolve_battle` / `server.apply_diplomacy` 仍不是模型可见工具名、provider function name、MCP `tools/list` 条目或玩家可伪造 call;公开 event 只显示可见证据摘要,不公开 evidence ref、raw proposal 或 hidden 情报。\n- S70.8/S70.14:补完整 provider prompt/tool eval、真实 MiMo required smoke、browser/JSON/SQLite parity 和归档。\n- S92.4:已新增旁路 `src/ai/tools/gameToolLoop.js`、`toolCallNormalizer.js`、`toolGuardrails.js` 与 `toolResultProjector.js`。该 loop 先用于 Mock/test-only,复用 S70.3 runner 的 schema、权限、冷却、server-owned resolver 和 audit 边界;provider-visible tool list 只含 function name、description、input schema;模型可见 tool result 只含 `status`、`publicResult`、`rejectionReasons`、`auditRef`、`modelFollowUpHint`。`privateResultRefs`、`appliedEventIds`、`counterCosts`、`followUpHooks`、developer audit、resolver label、permission/cooldown/mockFallback、raw provider payload、prompt、key、base URL、本地路径、`worldState`、`statePatch` 和内部 `server.*` 不得进入 provider-visible 回填。默认普通 turn、真实 provider tool calling 和服务器裁决能力仍未切换。\n"}}, "client/src/state/displayPreferenceStorage.ts": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785595227.7436774, "n": 3954, "h": "444939abc97a7fde", "c": "export const displayPreferenceStorageKey = \"qianqiu.displayPreferences.v1\";\nexport const displayPreferenceSchemaVersion = \"display-preferences-v1\";\n\nexport type DisplayPreferences = {\n readonly motion: \"full\" | \"reduced\";\n readonly textSize: \"standard\" | \"large\";\n readonly contrast: \"standard\" | \"high\";\n readonly bodyFont: \"serif-classic\" | \"song-xiaowei\" | \"kai-longcang\" | \"brush-mashan\";\n readonly autoScroll: boolean;\n readonly mapMotion: boolean;\n};\n\ntype DisplayPreferenceKey = keyof DisplayPreferences;\n\nexport const defaultDisplayPreferences: DisplayPreferences = {\n motion: \"full\",\n textSize: \"standard\",\n contrast: \"standard\",\n bodyFont: \"serif-classic\",\n autoScroll: true,\n mapMotion: true\n};\n\ntype StoredDisplayPreferences = {\n readonly schemaVersion?: unknown;\n readonly preferences?: unknown;\n};\n\nconst unsafePreferenceTextPattern = /\\/api\\/game\\/state|\\/api\\/dev\\/session-diagnostics|data[\\\\/]+sessions|[a-z]:[\\\\/]|file:\\/{2}|raw\\b|provider\\b|prompt\\b|hidden\\b|key\\b|path\\b|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[a-z0-9_-]+|完整提示词|提示词|本地路径|密钥|隐藏|私档|模型原始/i;\nconst displayPreferenceKeys: readonly DisplayPreferenceKey[] = [\"motion\", \"textSize\", \"contrast\", \"bodyFont\", \"autoScroll\", \"mapMotion\"];\n\nfunction getBrowserLocalStorage(): Storage | null {\n if (typeof window === \"undefined\") return null;\n try {\n return window.localStorage ?? null;\n } catch {\n return null;\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasUnsafePreferenceText(value: unknown) {\n return typeof value === \"string\" && unsafePreferenceTextPattern.test(value);\n}\n\nexport function isDisplayPreferenceValue<K extends keyof DisplayPreferences>(key: K, value: unknown): value is DisplayPreferences[K] {\n if (hasUnsafePreferenceText(value)) return false;\n if (key === \"motion\") return value === \"full\" || value === \"reduced\";\n if (key === \"textSize\") return value === \"standard\" || value === \"large\";\n if (key === \"contrast\") return value === \"standard\" || value === \"high\";\n if (key === \"bodyFont\") return value === \"serif-classic\" || value === \"song-xiaowei\" || value === \"kai-longcang\" || value === \"brush-mashan\";\n if (key === \"autoScroll\" || key === \"mapMotion\") return typeof value === \"boolean\";\n return false;\n}\n\nexport function sanitizeDisplayPreferences(value: unknown, defaults: DisplayPreferences = defaultDisplayPreferences): DisplayPreferences {\n if (!isRecord(value)) return defaults;\n\n const safePreferences = { ...defaults };\n for (const key of displayPreferenceKeys) {\n const nextValue = value[key];\n if (isDisplayPreferenceValue(key, nextValue)) {\n (safePreferences as Record<DisplayPreferenceKey, unknown>)[key] = nextValue;\n }\n }\n return safePreferences;\n}\n\nexport function loadDisplayPreferences(defaults: DisplayPreferences = defaultDisplayPreferences): DisplayPreferences {\n const storage = getBrowserLocalStorage();\n if (!storage) return defaults;\n\n try {\n const stored = storage.getItem(displayPreferenceStorageKey);\n if (!stored) return defaults;\n const parsed = JSON.parse(stored) as StoredDisplayPreferences;\n if (!isRecord(parsed) || parsed.schemaVersion !== displayPreferenceSchemaVersion) return defaults;\n return sanitizeDisplayPreferences(parsed.preferences, defaults);\n } catch {\n return defaults;\n }\n}\n\nexport function saveDisplayPreferences(preferences: DisplayPreferences): DisplayPreferences {\n const storage = getBrowserLocalStorage();\n const safePreferences = sanitizeDisplayPreferences(preferences, defaultDisplayPreferences);\n if (!storage) return safePreferences;\n\n const payload = {\n schemaVersion: displayPreferenceSchemaVersion,\n preferences: safePreferences\n };\n\n try {\n storage.setItem(displayPreferenceStorageKey, JSON.stringify(payload));\n } catch {\n }\n return safePreferences;\n}\n"}}, "test/topicSurfaceView.test.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745537.717415, "n": 17475, "h": "e1c6cd10071b8084", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst { recordNpcInteraction } = require(\"../src/game/npcInteractions\");\nconst { resolveNpcRelationshipAction } = require(\"../src/game/npcRelationshipActions\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { applyWorldEntityInfluences } = require(\"../src/game/worldEntities\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { readSessionRecord, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"S88.8 topic surfaces route economy trace evidence by safe topic allowlist\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"经济专题\"\n });\n worldState.turnCount = 20;\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger.recentEvents = [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\"\n ];\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:topic:economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n const warCouncil = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"economyTraceView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /交易议价|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|委派|预算/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /人情债|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /资产维护|保养|库存/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"economyTraceView\"), false);\n assert.ok(warCouncil.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|预算|市价/.test(`${ref.label}${ref.summary}`)\n ));\n assertNoSensitiveText({ memorial, profile, trial, warCouncil });\n});\n\ntest(\"S88.7 topic surfaces expose world entity impact evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"实体专题\"\n });\n worldState.turnCount = 26;\n applyWorldEntityInfluences(worldState, [\n {\n entityId: \"academy-same-year-circle\",\n sourceType: \"npc_relationship_action\",\n sourceId: \"npc-relationship-resolution:npc-scholar-peer-shen:debate:26\",\n metricsDelta: { trust: 2, pressure: -1 },\n publicNote: \"论道余波进入同年文社\"\n },\n {\n entityId: \"court-censorate\",\n sourceType: \"active_npc_request\",\n sourceId: \"data/sessions/rawLedger-providerPayload-safe_search_index\",\n metricsDelta: { pressure: 2 },\n publicNote: \"来函后续已登记为风宪证据观察\"\n }\n ]);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const edict = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"worldEntityView\"));\n assert.ok(edict.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /风宪证据观察|都察院|压力/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(debate.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /论道余波|同年文社|信任/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" &&\n ref.topicSurfaceIds?.includes(\"npc-profile\") &&\n /论道余波|同年文社/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"worldEntityView\"), false);\n assertNoSensitiveText({ memorial, edict, debate, profile, trial });\n});\n\ntest(\"S88.7 topic surfaces expose NPC relationship action evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"scholar\",\n playerName: \"交游专题\"\n });\n worldState.turnCount = 29;\n const relationshipAction = resolveNpcRelationshipAction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\",\n mood: \"昂然\"\n });\n assert.equal(relationshipAction.ok, true);\n const recorded = recordNpcInteraction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n utterance: \"只作公开切磋,不许伤人。\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\"\n }, {\n resolutionView: relationshipAction.resolutionView\n });\n recorded.record.outcomeSummary = \"provider_payload hidden_dossier /mnt/e/secret safe_search_index\";\n\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n for (const view of [profile, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"npcInteractionView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcInteractionView\" &&\n ref.topicSurfaceIds?.includes(view.surfaceId) &&\n /交游记录|沈砚秋|切磋|服务器裁决/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.items.some((item) =>\n item.sourceView === \"npcInteractionView\" &&\n /沈砚秋|切磋/.test(`${item.title}${item.summary}`)\n ));\n }\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"npcInteractionView\"), false);\n assertNoSensitiveText({ profile, debate, trial });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const { record: beforeRecord } = await readSessionRecord(worldState.sessionId);\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const { record: afterRecord } = await readSessionRecord(worldState.sessionId);\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRecord.revision, beforeRecord.revision);\n assert.deepEqual(afterRecord.worldState, beforeRecord.worldState);\n});\n"}, "37b350497148c260b22d5729e175c85531a18470": {"t": 1785753930.7718554, "n": 17475, "h": "e1c6cd10071b8084", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst { recordNpcInteraction } = require(\"../src/game/npcInteractions\");\nconst { resolveNpcRelationshipAction } = require(\"../src/game/npcRelationshipActions\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { applyWorldEntityInfluences } = require(\"../src/game/worldEntities\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { readSessionRecord, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"S88.8 topic surfaces route economy trace evidence by safe topic allowlist\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"经济专题\"\n });\n worldState.turnCount = 20;\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger.recentEvents = [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\"\n ];\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:topic:economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n const warCouncil = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"economyTraceView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /交易议价|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|委派|预算/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /人情债|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /资产维护|保养|库存/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"economyTraceView\"), false);\n assert.ok(warCouncil.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|预算|市价/.test(`${ref.label}${ref.summary}`)\n ));\n assertNoSensitiveText({ memorial, profile, trial, warCouncil });\n});\n\ntest(\"S88.7 topic surfaces expose world entity impact evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"实体专题\"\n });\n worldState.turnCount = 26;\n applyWorldEntityInfluences(worldState, [\n {\n entityId: \"academy-same-year-circle\",\n sourceType: \"npc_relationship_action\",\n sourceId: \"npc-relationship-resolution:npc-scholar-peer-shen:debate:26\",\n metricsDelta: { trust: 2, pressure: -1 },\n publicNote: \"论道余波进入同年文社\"\n },\n {\n entityId: \"court-censorate\",\n sourceType: \"active_npc_request\",\n sourceId: \"data/sessions/rawLedger-providerPayload-safe_search_index\",\n metricsDelta: { pressure: 2 },\n publicNote: \"来函后续已登记为风宪证据观察\"\n }\n ]);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const edict = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"worldEntityView\"));\n assert.ok(edict.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /风宪证据观察|都察院|压力/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(debate.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /论道余波|同年文社|信任/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" &&\n ref.topicSurfaceIds?.includes(\"npc-profile\") &&\n /论道余波|同年文社/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"worldEntityView\"), false);\n assertNoSensitiveText({ memorial, edict, debate, profile, trial });\n});\n\ntest(\"S88.7 topic surfaces expose NPC relationship action evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"scholar\",\n playerName: \"交游专题\"\n });\n worldState.turnCount = 29;\n const relationshipAction = resolveNpcRelationshipAction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\",\n mood: \"昂然\"\n });\n assert.equal(relationshipAction.ok, true);\n const recorded = recordNpcInteraction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n utterance: \"只作公开切磋,不许伤人。\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\"\n }, {\n resolutionView: relationshipAction.resolutionView\n });\n recorded.record.outcomeSummary = \"provider_payload hidden_dossier /mnt/e/secret safe_search_index\";\n\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n for (const view of [profile, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"npcInteractionView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcInteractionView\" &&\n ref.topicSurfaceIds?.includes(view.surfaceId) &&\n /交游记录|沈砚秋|切磋|服务器裁决/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.items.some((item) =>\n item.sourceView === \"npcInteractionView\" &&\n /沈砚秋|切磋/.test(`${item.title}${item.summary}`)\n ));\n }\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"npcInteractionView\"), false);\n assertNoSensitiveText({ profile, debate, trial });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const { record: beforeRecord } = await readSessionRecord(worldState.sessionId);\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const { record: afterRecord } = await readSessionRecord(worldState.sessionId);\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRecord.revision, beforeRecord.revision);\n assert.deepEqual(afterRecord.worldState, beforeRecord.worldState);\n});\n"}, "cb96028b5666afa0c38d1452a9bbec559878d71d": {"t": 1785753934.366717, "n": 17363, "h": "cb68ce2636bbffab", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst { recordNpcInteraction } = require(\"../src/game/npcInteractions\");\nconst { resolveNpcRelationshipAction } = require(\"../src/game/npcRelationshipActions\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { applyWorldEntityInfluences } = require(\"../src/game/worldEntities\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"S88.8 topic surfaces route economy trace evidence by safe topic allowlist\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"经济专题\"\n });\n worldState.turnCount = 20;\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger.recentEvents = [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\"\n ];\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:topic:economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n const warCouncil = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"economyTraceView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /交易议价|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|委派|预算/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /人情债|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /资产维护|保养|库存/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"economyTraceView\"), false);\n assert.ok(warCouncil.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|预算|市价/.test(`${ref.label}${ref.summary}`)\n ));\n assertNoSensitiveText({ memorial, profile, trial, warCouncil });\n});\n\ntest(\"S88.7 topic surfaces expose world entity impact evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"实体专题\"\n });\n worldState.turnCount = 26;\n applyWorldEntityInfluences(worldState, [\n {\n entityId: \"academy-same-year-circle\",\n sourceType: \"npc_relationship_action\",\n sourceId: \"npc-relationship-resolution:npc-scholar-peer-shen:debate:26\",\n metricsDelta: { trust: 2, pressure: -1 },\n publicNote: \"论道余波进入同年文社\"\n },\n {\n entityId: \"court-censorate\",\n sourceType: \"active_npc_request\",\n sourceId: \"data/sessions/rawLedger-providerPayload-safe_search_index\",\n metricsDelta: { pressure: 2 },\n publicNote: \"来函后续已登记为风宪证据观察\"\n }\n ]);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const edict = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"worldEntityView\"));\n assert.ok(edict.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /风宪证据观察|都察院|压力/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(debate.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /论道余波|同年文社|信任/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" &&\n ref.topicSurfaceIds?.includes(\"npc-profile\") &&\n /论道余波|同年文社/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"worldEntityView\"), false);\n assertNoSensitiveText({ memorial, edict, debate, profile, trial });\n});\n\ntest(\"S88.7 topic surfaces expose NPC relationship action evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"scholar\",\n playerName: \"交游专题\"\n });\n worldState.turnCount = 29;\n const relationshipAction = resolveNpcRelationshipAction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\",\n mood: \"昂然\"\n });\n assert.equal(relationshipAction.ok, true);\n const recorded = recordNpcInteraction(worldState, {\n npcId: \"npc:scholar:peer-shen\",\n actionType: \"duel\",\n utterance: \"只作公开切磋,不许伤人。\",\n winner: \"player\",\n injury: \"npc_injured\"\n }, {\n dialogueText: \"沈砚秋愿以射艺和步法切磋,胜负仍候裁断。\"\n }, {\n resolutionView: relationshipAction.resolutionView\n });\n recorded.record.outcomeSummary = \"provider_payload hidden_dossier /mnt/e/secret safe_search_index\";\n\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n for (const view of [profile, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"npcInteractionView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcInteractionView\" &&\n ref.topicSurfaceIds?.includes(view.surfaceId) &&\n /交游记录|沈砚秋|切磋|服务器裁决/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.items.some((item) =>\n item.sourceView === \"npcInteractionView\" &&\n /沈砚秋|切磋/.test(`${item.title}${item.summary}`)\n ));\n }\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"npcInteractionView\"), false);\n assertNoSensitiveText({ profile, debate, trial });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "7d63e72aefde163dbca721beaa5562341e5e591e": {"t": 1785753936.3169599, "n": 15335, "h": "e0553cb712dd00af", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { applyWorldEntityInfluences } = require(\"../src/game/worldEntities\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"S88.8 topic surfaces route economy trace evidence by safe topic allowlist\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"经济专题\"\n });\n worldState.turnCount = 20;\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger.recentEvents = [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\"\n ];\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:topic:economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n const warCouncil = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"economyTraceView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /交易议价|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|委派|预算/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /人情债|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /资产维护|保养|库存/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"economyTraceView\"), false);\n assert.ok(warCouncil.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|预算|市价/.test(`${ref.label}${ref.summary}`)\n ));\n assertNoSensitiveText({ memorial, profile, trial, warCouncil });\n});\n\ntest(\"S88.7 topic surfaces expose world entity impact evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"实体专题\"\n });\n worldState.turnCount = 26;\n applyWorldEntityInfluences(worldState, [\n {\n entityId: \"academy-same-year-circle\",\n sourceType: \"npc_relationship_action\",\n sourceId: \"npc-relationship-resolution:npc-scholar-peer-shen:debate:26\",\n metricsDelta: { trust: 2, pressure: -1 },\n publicNote: \"论道余波进入同年文社\"\n },\n {\n entityId: \"court-censorate\",\n sourceType: \"active_npc_request\",\n sourceId: \"data/sessions/rawLedger-providerPayload-safe_search_index\",\n metricsDelta: { pressure: 2 },\n publicNote: \"来函后续已登记为风宪证据观察\"\n }\n ]);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const edict = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"worldEntityView\"));\n assert.ok(edict.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /风宪证据观察|都察院|压力/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(debate.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" && /论道余波|同年文社|信任/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"worldEntityView\" &&\n ref.topicSurfaceIds?.includes(\"npc-profile\") &&\n /论道余波|同年文社/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"worldEntityView\"), false);\n assertNoSensitiveText({ memorial, edict, debate, profile, trial });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "62c9a26038595a5a3a5a7072fa45797b557b3782": {"t": 1785753937.5307379, "n": 13295, "h": "a8fb5c03ecf3b516", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"S88.8 topic surfaces route economy trace evidence by safe topic allowlist\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"经济专题\"\n });\n worldState.turnCount = 20;\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger.recentEvents = [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\"\n ];\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:topic:economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n const warCouncil = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"economyTraceView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /交易议价|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|委派|预算/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /人情债|韩员外/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /资产维护|保养|库存/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.equal(trial.evidenceRefs.some((ref) => ref.sourceView === \"economyTraceView\"), false);\n assert.ok(warCouncil.evidenceRefs.some((ref) =>\n ref.sourceView === \"economyTraceView\" && /丈量田亩|预算|市价/.test(`${ref.label}${ref.summary}`)\n ));\n assertNoSensitiveText({ memorial, profile, trial, warCouncil });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "73d6540d0aaf6f5595caf050a1a74d87fa65c8ce": {"t": 1785753938.9179828, "n": 10859, "h": "17d1b71e19b012f1", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n createNpcActiveRequest,\n resolveNpcActiveRequest\n} = require(\"../src/game/npcActiveRequests\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.7 topic surfaces expose NPC follow-up evidence as read-only material\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"来函专题\"\n });\n worldState.turnCount = 16;\n const petition = createNpcActiveRequest(worldState, \"petition\");\n const introduction = createNpcActiveRequest(worldState, \"introduction\");\n assert.equal(petition.ok, true);\n assert.equal(introduction.ok, true);\n resolveNpcActiveRequest(worldState, petition.request.requestId, \"investigate\");\n resolveNpcActiveRequest(worldState, introduction.request.requestId, \"accept\");\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const profile = buildTopicSurfaceView(worldState, { surfaceId: \"npc-profile\" });\n const trial = buildTopicSurfaceView(worldState, { surfaceId: \"trial\" });\n\n assert.ok(memorial.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(memorial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(profile.sourceViews.some((source) => source.sourceView === \"npcActiveRequestView\"));\n assert.ok(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(profile.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ), false);\n assert.ok(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /请托|案牍|公私边界/.test(`${ref.label}${ref.summary}`)\n ));\n assert.equal(trial.evidenceRefs.some((ref) =>\n ref.sourceView === \"npcActiveRequestView\" && /引荐|拜会|师友|同年/.test(`${ref.label}${ref.summary}`)\n ), false);\n assertNoSensitiveText({ memorial, profile, trial });\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "9a7a934bcd53ae10cb28c63013eda06a04eefc90": {"t": 1785753940.3681831, "n": 8857, "h": "4b2ebaada5b02cb9", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { ensureWorldThreadState } = require(\"../src/game/worldThreads\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S88.6 topic surfaces dedupe domain consequence archive and thread echoes\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"后果去重\"\n });\n worldState.turnCount = 18;\n worldState.cityPolicyLedger = {\n records: [{\n outcomeId: \"topic-surface-domain-echo\",\n policyType: \"market_regulation\",\n policyLabel: \"米价回响\",\n status: \"accepted\",\n publicSummary: \"米铺照牌价出售,仍需观察民情。\",\n publicSourceId: \"topic-surface-domain-public-source\",\n stateDelta: { publicOrder: -3 },\n appliedAtTurn: 18\n }]\n };\n ensureWorldThreadState(worldState);\n\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"edict-draft\" });\n const directRef = view.evidenceRefs.find((ref) => ref.sourceView === \"domainConsequenceView\");\n const echoRef = directRef?.canonicalEchoRefs?.[0] || \"\";\n const echoMatches = view.evidenceRefs.filter((ref) => ref.canonicalEchoRefs?.includes(echoRef));\n\n assert.match(echoRef, /^domainConsequenceEcho:/);\n assert.equal(echoMatches.length, 1);\n assert.equal(echoMatches[0].sourceView, \"domainConsequenceView\");\n assertNoSensitiveText(view);\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "2eabfcb2b97993b639dd4ef0809c5be6361776fc": {"t": 1785753943.6135101, "n": 7607, "h": "ab1f08878ab19c3f", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { buildOfficialCareerView } = require(\"../src/game/officialCareer\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n const entryId = buildOfficialCareerView(worldState).courtEntry.id;\n worldState.officialCareer.courtEntryResolutions = [{\n id: \"OCER-topic-court-entry\",\n entryId,\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n surfaceId: \"memorial-review\",\n submissionKind: \"official_first_month_memorial\",\n status: \"accepted_for_review\",\n statusLabel: \"准入复核\",\n title: \"准入复核:馆阁讲章校订\",\n publicSummary: \"准入复核:馆阁讲章校订已入奏折队列服务器裁决,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 2,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"由部院复核公开凭据后再入长期考成。\"\n }];\n worldState.officialCareer.courtEntryFollowUps = [{\n id: \"OCEF-topic-follow-up\",\n entryId,\n resolutionId: \"OCER-topic-court-entry\",\n assignmentId: \"ASG-0000-first-month-top_hanlin_editor\",\n stage: \"bureau_review\",\n stageLabel: \"部院覆奏\",\n status: \"referred_to_bureau\",\n statusLabel: \"部院待覆\",\n title: \"部院覆奏:馆阁讲章校订\",\n publicSummary: \"部院待覆:馆阁讲章校订承接近次准入复核进入部院覆奏,皇帝、部院、台谏只形成公开中间意见,不直接任免、奖惩、处分或成弹劾。\",\n generatedAtTurn: 3,\n sourceRefs: [`officialCareer.courtEntry:${entryId}`],\n nextStep: \"相关部院待覆,下一步补齐公开凭据、限期和经手人。\"\n }];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /部院待覆|部院覆奏/.test(ref.summary)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "345d134dfe5ab48062b6bb83e787183c02d49891": {"t": 1785753945.1020734, "n": 6260, "h": "cdcab0195f0b3a9e", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"S88.4 memorial and court surfaces include official first-month court entry evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"首月奏议\"\n });\n Object.assign(worldState.player, {\n officeTitle: \"翰林院编修\",\n position: \"翰林院编修\",\n performanceMerit: 52,\n impeachmentRisk: 18\n });\n worldState.officialCareer.currentPosting = \"翰林院编修\";\n worldState.officialCareer.assignments = [{\n id: \"ASG-0000-first-month-top_hanlin_editor\",\n title: \"馆阁讲章校订\",\n kind: \"memorial_drafting\",\n bureauId: \"hanlin_academy\",\n dueTurn: 3,\n deadlineUnit: \"ten_day\",\n progress: 48,\n risk: 18,\n visibleSummary: \"首月须校订馆阁讲章并试拟制诰。\",\n hiddenNotes: [\"堂官私下试探\"]\n }];\n worldState.officialCareer.assessmentDossier.notes = [\n \"讲章回署可入考成。\",\n \"provider payload prompt raw_table\"\n ];\n\n const memorial = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const debate = buildTopicSurfaceView(worldState, { surfaceId: \"court-debate\" });\n\n for (const view of [memorial, debate]) {\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"officialCareerView\"));\n assert.ok(view.evidenceRefs.some((ref) =>\n ref.sourceView === \"officialCareerView\" && /首月回署|馆阁讲章校订/.test(`${ref.label}${ref.summary}`)\n ));\n assert.ok(view.items.some((item) => /首月回署|馆阁讲章校订/.test(`${item.title}${item.summary}`)));\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}, "f2367bf621a8b74c428ace20a381d52c7d7681dc": {"t": 1785753946.8775473, "n": 4792, "h": "c77dc9858c508246", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nprocess.env.AI_PROVIDER = \"mock\";\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n buildTopicSurfaceView,\n buildTopicSurfaceViewIndex\n} = require(\"../src/game/topicSurfaceView\");\nconst { writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst topicSurfaceIds = [\n \"memorial-review\",\n \"edict-draft\",\n \"court-debate\",\n \"trial\",\n \"war-council\",\n \"npc-profile\"\n];\n\nfunction sessionPath(sessionId) {\n return path.join(sessionsDir, `${sessionId}.json`);\n}\n\nasync function removeSessionArtifacts(sessionId) {\n if (!sessionId) return;\n await Promise.all([\n fs.rm(sessionPath(sessionId), { force: true }),\n fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true }),\n fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true })\n ]);\n}\n\nfunction assertNoSensitiveText(payload) {\n const serialized = JSON.stringify(payload);\n assert.doesNotMatch(\n serialized,\n /SEALED_|hidden[ _-]?(?:notes?|intent)?|raw[ _-]?(?:provider|payload|prompt|audit|table|ledger|state|row)|provider\\s+payload|prompt|完整提示词|localPath|file:\\/\\/|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/]|\\/(?:mnt|home|Users|tmp|var|opt|workspace)\\/|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}/i\n );\n}\n\nfunction createGameServer() {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", require(\"../src/routes/game\"));\n app.use((err, req, res, next) => {\n res.status(err.statusCode || 500).json({ error: err.message || \"Internal server error\" });\n });\n return createFetchSafeServer(app);\n}\n\ntest(\"S78 topicSurfaceView covers six safe office topic surfaces\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"official\",\n playerName: \"专题测试\"\n });\n const views = buildTopicSurfaceViewIndex(worldState);\n\n assert.deepEqual(views.map((view) => view.surfaceId).sort(), topicSurfaceIds.slice().sort());\n for (const view of views) {\n assert.equal(view.schemaVersion, \"s78.topicSurfaceView.v1\");\n assert.equal(view.safety.readOnly, true);\n assert.equal(view.safety.draftOnly, true);\n assert.equal(view.safety.noResolverExecution, true);\n assert.equal(view.safety.noStateWrites, true);\n assert.ok(Array.isArray(view.items));\n assert.ok(Array.isArray(view.evidenceRefs));\n assert.ok(Array.isArray(view.draftSlots));\n assert.ok(view.draftSlots.length >= 1, view.surfaceId);\n assertNoSensitiveText(view);\n }\n});\n\ntest(\"S78 topicSurfaceView rejects unknown surface ids\", () => {\n const worldState = createInitialState({ role: \"official\" });\n assert.throws(() => buildTopicSurfaceView(worldState, { surfaceId: \"raw-state\" }), /未知专题/);\n});\n\ntest(\"S78 war council surface includes safe map context evidence\", () => {\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"general\",\n playerName: \"军议舆图\"\n });\n const view = buildTopicSurfaceView(worldState, { surfaceId: \"war-council\" });\n\n assert.ok(view.sourceViews.some((source) => source.sourceView === \"mapContextView\"));\n assert.ok(view.evidenceRefs.some((ref) => ref.sourceView === \"mapContextView\"));\n assertNoSensitiveText(view);\n});\n\ntest(\"GET /api/game/topic-surface/:sessionId/:surfaceId returns read-only safe projection\", async (t) => {\n const server = createGameServer();\n const worldState = createInitialState({\n dynasty: \"明\",\n year: 1644,\n role: \"magistrate\",\n playerName: \"堂审测试\"\n });\n await writeSession(worldState);\n t.after(async () => {\n await removeSessionArtifacts(worldState.sessionId);\n await server.close();\n });\n\n const beforeRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n const response = await fetch(`${server.baseUrl}/api/game/topic-surface/${worldState.sessionId}/trial`);\n const payload = await response.json();\n const afterRaw = await fs.readFile(sessionPath(worldState.sessionId), \"utf8\");\n\n assert.equal(response.status, 200);\n assert.equal(payload.sessionId, worldState.sessionId);\n assert.equal(payload.topicSurfaceView.surfaceId, \"trial\");\n assert.ok(payload.topicSurfaceView.items.length >= 1);\n assert.ok(payload.topicSurfaceView.evidenceRefs.length >= 1);\n assertNoSensitiveText(payload);\n assert.equal(afterRaw, beforeRaw);\n});\n"}}, "test/streamingTurnRoute.test.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745539.6549253, "n": 25804, "h": "33515b5bf88459c6", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { buildMapRuntimeView } = require(\"../src/game/mapRuntimeView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction buildMapRuntimeDraftContext(worldState = {}, entityType = \"military_report\") {\n const view = buildMapRuntimeView(worldState);\n const ref = view.refs.find((entry) => entry.entityType === entityType);\n assert.ok(ref, `expected a safe ${entityType} map runtime ref`);\n const [draftId] = Object.entries(view.actionDrafts || {}).find(([, draft]) => draft.targetRef === ref.mapEntityRef) || [];\n assert.ok(draftId, `expected a safe ${entityType} map runtime action draft`);\n return {\n surfaceId: \"map-runtime\",\n draftKind: \"map_ref_action\",\n sourceView: \"mapRuntimeView\",\n draftId,\n evidenceRefs: [ref.mapEntityRef, ref.sourceRef, \"map:forged:secret\", \"layoutPath\", \"mapBounds:0:1\"],\n sourceRefs: [ref.sourceRef, \"viewportHint\", \"x:0.5\", \"domainConsequenceView:visual-only\"],\n targetRefs: [ref.mapEntityRef, \"providerPayload\"],\n actionDraftRefs: [draftId, \"draft-forged-secret\"],\n requiresServerTurn: true,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"tradeRouteLedger\":|\"caravanLedger\":|\"economicContractLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(preview.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(preview.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(preview.data.playerEnterpriseView.source, \"server_player_enterprise_projection\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(finalState.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(finalState.data.playerEnterpriseView.source, \"server_player_enterprise_projection\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.10 true provider stream revalidates map-runtime military draftContext\", async (t) => {\n const payload = makeTurnPayload(\"军议舆图草稿已随流式回合复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"general\", playerName: \"流式舆图将领\" });\n const draftContext = buildMapRuntimeDraftContext(worldState, \"military_report\");\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"据舆图军议,遣哨侦察边报与粮道虚实,先呈公开军情。\";\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const saved = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({\n roleCycleDomainAdjudication: finalState?.data?.roleCycleDomainAdjudication,\n worldState: finalState?.data?.worldState,\n attributeChanges: finalState?.data?.attributeChanges,\n relationshipChanges: finalState?.data?.relationshipChanges\n });\n const outcome = finalState?.data?.roleCycleDomainAdjudication?.outcome;\n const mapContext = outcome?.mapRuntimeDraftContext;\n\n assert.equal(runTurnCalls, 0);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(outcome.status, \"accepted\");\n assert.equal(outcome.resolver, \"military_diplomacy\");\n assert.equal(outcome.intent, \"scout\");\n assert.equal(mapContext.surfaceId, \"map-runtime\");\n assert.equal(mapContext.status, \"verified\");\n assert.deepEqual(mapContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(mapContext.actionDraftRefs, [draftContext.draftId]);\n assert.equal(mapContext.evidenceRefs.includes(\"map:forged:secret\"), false);\n assert.equal(finalState.data.worldState?.militaryDiplomacyLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /map:forged:secret|draft-forged-secret|layoutPath|mapBounds|viewportHint|providerPayload|domainConsequenceView:visual-only|\"militaryDiplomacyLedger\":|\"cityPolicyLedger\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|rawSql|SEALED_/i\n );\n assert.equal(saved.militaryDiplomacyLedger.records.length, 1);\n assert.equal(saved.militaryDiplomacyLedger.records[0].actionKind, \"scout\");\n assert.equal(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.surfaceId, \"map-runtime\");\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.actionDraftRefs, [draftContext.draftId]);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "4884269f8f49954c18cc37cdae2345145bc442f1": {"t": 1785753935.1692023, "n": 25804, "h": "33515b5bf88459c6", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { buildMapRuntimeView } = require(\"../src/game/mapRuntimeView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction buildMapRuntimeDraftContext(worldState = {}, entityType = \"military_report\") {\n const view = buildMapRuntimeView(worldState);\n const ref = view.refs.find((entry) => entry.entityType === entityType);\n assert.ok(ref, `expected a safe ${entityType} map runtime ref`);\n const [draftId] = Object.entries(view.actionDrafts || {}).find(([, draft]) => draft.targetRef === ref.mapEntityRef) || [];\n assert.ok(draftId, `expected a safe ${entityType} map runtime action draft`);\n return {\n surfaceId: \"map-runtime\",\n draftKind: \"map_ref_action\",\n sourceView: \"mapRuntimeView\",\n draftId,\n evidenceRefs: [ref.mapEntityRef, ref.sourceRef, \"map:forged:secret\", \"layoutPath\", \"mapBounds:0:1\"],\n sourceRefs: [ref.sourceRef, \"viewportHint\", \"x:0.5\", \"domainConsequenceView:visual-only\"],\n targetRefs: [ref.mapEntityRef, \"providerPayload\"],\n actionDraftRefs: [draftId, \"draft-forged-secret\"],\n requiresServerTurn: true,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"tradeRouteLedger\":|\"caravanLedger\":|\"economicContractLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(preview.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(preview.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(preview.data.playerEnterpriseView.source, \"server_player_enterprise_projection\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(finalState.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(finalState.data.playerEnterpriseView.source, \"server_player_enterprise_projection\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.10 true provider stream revalidates map-runtime military draftContext\", async (t) => {\n const payload = makeTurnPayload(\"军议舆图草稿已随流式回合复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"general\", playerName: \"流式舆图将领\" });\n const draftContext = buildMapRuntimeDraftContext(worldState, \"military_report\");\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"据舆图军议,遣哨侦察边报与粮道虚实,先呈公开军情。\";\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const saved = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({\n roleCycleDomainAdjudication: finalState?.data?.roleCycleDomainAdjudication,\n worldState: finalState?.data?.worldState,\n attributeChanges: finalState?.data?.attributeChanges,\n relationshipChanges: finalState?.data?.relationshipChanges\n });\n const outcome = finalState?.data?.roleCycleDomainAdjudication?.outcome;\n const mapContext = outcome?.mapRuntimeDraftContext;\n\n assert.equal(runTurnCalls, 0);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(outcome.status, \"accepted\");\n assert.equal(outcome.resolver, \"military_diplomacy\");\n assert.equal(outcome.intent, \"scout\");\n assert.equal(mapContext.surfaceId, \"map-runtime\");\n assert.equal(mapContext.status, \"verified\");\n assert.deepEqual(mapContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(mapContext.actionDraftRefs, [draftContext.draftId]);\n assert.equal(mapContext.evidenceRefs.includes(\"map:forged:secret\"), false);\n assert.equal(finalState.data.worldState?.militaryDiplomacyLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /map:forged:secret|draft-forged-secret|layoutPath|mapBounds|viewportHint|providerPayload|domainConsequenceView:visual-only|\"militaryDiplomacyLedger\":|\"cityPolicyLedger\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|rawSql|SEALED_/i\n );\n assert.equal(saved.militaryDiplomacyLedger.records.length, 1);\n assert.equal(saved.militaryDiplomacyLedger.records[0].actionKind, \"scout\");\n assert.equal(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.surfaceId, \"map-runtime\");\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.actionDraftRefs, [draftContext.draftId]);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "87fddffac0a32c4ea32410e72ebce508045844dc": {"t": 1785753941.4797845, "n": 25607, "h": "ed312753a40976d8", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { buildMapRuntimeView } = require(\"../src/game/mapRuntimeView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction buildMapRuntimeDraftContext(worldState = {}, entityType = \"military_report\") {\n const view = buildMapRuntimeView(worldState);\n const ref = view.refs.find((entry) => entry.entityType === entityType);\n assert.ok(ref, `expected a safe ${entityType} map runtime ref`);\n const [draftId] = Object.entries(view.actionDrafts || {}).find(([, draft]) => draft.targetRef === ref.mapEntityRef) || [];\n assert.ok(draftId, `expected a safe ${entityType} map runtime action draft`);\n return {\n surfaceId: \"map-runtime\",\n draftKind: \"map_ref_action\",\n sourceView: \"mapRuntimeView\",\n draftId,\n evidenceRefs: [ref.mapEntityRef, ref.sourceRef, \"map:forged:secret\", \"layoutPath\", \"mapBounds:0:1\"],\n sourceRefs: [ref.sourceRef, \"viewportHint\", \"x:0.5\", \"domainConsequenceView:visual-only\"],\n targetRefs: [ref.mapEntityRef, \"providerPayload\"],\n actionDraftRefs: [draftId, \"draft-forged-secret\"],\n requiresServerTurn: true,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"tradeRouteLedger\":|\"caravanLedger\":|\"economicContractLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(preview.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(preview.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(finalState.data.tradeRouteView.source, \"server_trade_route_projection\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.10 true provider stream revalidates map-runtime military draftContext\", async (t) => {\n const payload = makeTurnPayload(\"军议舆图草稿已随流式回合复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"general\", playerName: \"流式舆图将领\" });\n const draftContext = buildMapRuntimeDraftContext(worldState, \"military_report\");\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"据舆图军议,遣哨侦察边报与粮道虚实,先呈公开军情。\";\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const saved = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({\n roleCycleDomainAdjudication: finalState?.data?.roleCycleDomainAdjudication,\n worldState: finalState?.data?.worldState,\n attributeChanges: finalState?.data?.attributeChanges,\n relationshipChanges: finalState?.data?.relationshipChanges\n });\n const outcome = finalState?.data?.roleCycleDomainAdjudication?.outcome;\n const mapContext = outcome?.mapRuntimeDraftContext;\n\n assert.equal(runTurnCalls, 0);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(outcome.status, \"accepted\");\n assert.equal(outcome.resolver, \"military_diplomacy\");\n assert.equal(outcome.intent, \"scout\");\n assert.equal(mapContext.surfaceId, \"map-runtime\");\n assert.equal(mapContext.status, \"verified\");\n assert.deepEqual(mapContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(mapContext.actionDraftRefs, [draftContext.draftId]);\n assert.equal(mapContext.evidenceRefs.includes(\"map:forged:secret\"), false);\n assert.equal(finalState.data.worldState?.militaryDiplomacyLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /map:forged:secret|draft-forged-secret|layoutPath|mapBounds|viewportHint|providerPayload|domainConsequenceView:visual-only|\"militaryDiplomacyLedger\":|\"cityPolicyLedger\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|rawSql|SEALED_/i\n );\n assert.equal(saved.militaryDiplomacyLedger.records.length, 1);\n assert.equal(saved.militaryDiplomacyLedger.records[0].actionKind, \"scout\");\n assert.equal(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.surfaceId, \"map-runtime\");\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.actionDraftRefs, [draftContext.draftId]);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "bd4d3f531ec08f5f352aa01b0335d52b5ea47d70": {"t": 1785753942.9847775, "n": 25371, "h": "3ec1f6beb8416e76", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { buildMapRuntimeView } = require(\"../src/game/mapRuntimeView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction buildMapRuntimeDraftContext(worldState = {}, entityType = \"military_report\") {\n const view = buildMapRuntimeView(worldState);\n const ref = view.refs.find((entry) => entry.entityType === entityType);\n assert.ok(ref, `expected a safe ${entityType} map runtime ref`);\n const [draftId] = Object.entries(view.actionDrafts || {}).find(([, draft]) => draft.targetRef === ref.mapEntityRef) || [];\n assert.ok(draftId, `expected a safe ${entityType} map runtime action draft`);\n return {\n surfaceId: \"map-runtime\",\n draftKind: \"map_ref_action\",\n sourceView: \"mapRuntimeView\",\n draftId,\n evidenceRefs: [ref.mapEntityRef, ref.sourceRef, \"map:forged:secret\", \"layoutPath\", \"mapBounds:0:1\"],\n sourceRefs: [ref.sourceRef, \"viewportHint\", \"x:0.5\", \"domainConsequenceView:visual-only\"],\n targetRefs: [ref.mapEntityRef, \"providerPayload\"],\n actionDraftRefs: [draftId, \"draft-forged-secret\"],\n requiresServerTurn: true,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(preview.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.cityEconomyView.source, \"server_city_market_projection\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.10 true provider stream revalidates map-runtime military draftContext\", async (t) => {\n const payload = makeTurnPayload(\"军议舆图草稿已随流式回合复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"general\", playerName: \"流式舆图将领\" });\n const draftContext = buildMapRuntimeDraftContext(worldState, \"military_report\");\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"据舆图军议,遣哨侦察边报与粮道虚实,先呈公开军情。\";\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const saved = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({\n roleCycleDomainAdjudication: finalState?.data?.roleCycleDomainAdjudication,\n worldState: finalState?.data?.worldState,\n attributeChanges: finalState?.data?.attributeChanges,\n relationshipChanges: finalState?.data?.relationshipChanges\n });\n const outcome = finalState?.data?.roleCycleDomainAdjudication?.outcome;\n const mapContext = outcome?.mapRuntimeDraftContext;\n\n assert.equal(runTurnCalls, 0);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(outcome.status, \"accepted\");\n assert.equal(outcome.resolver, \"military_diplomacy\");\n assert.equal(outcome.intent, \"scout\");\n assert.equal(mapContext.surfaceId, \"map-runtime\");\n assert.equal(mapContext.status, \"verified\");\n assert.deepEqual(mapContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(mapContext.actionDraftRefs, [draftContext.draftId]);\n assert.equal(mapContext.evidenceRefs.includes(\"map:forged:secret\"), false);\n assert.equal(finalState.data.worldState?.militaryDiplomacyLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /map:forged:secret|draft-forged-secret|layoutPath|mapBounds|viewportHint|providerPayload|domainConsequenceView:visual-only|\"militaryDiplomacyLedger\":|\"cityPolicyLedger\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|rawSql|SEALED_/i\n );\n assert.equal(saved.militaryDiplomacyLedger.records.length, 1);\n assert.equal(saved.militaryDiplomacyLedger.records[0].actionKind, \"scout\");\n assert.equal(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.surfaceId, \"map-runtime\");\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.actionDraftRefs, [draftContext.draftId]);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "d7bf0e7adabf028b2ed6fde9f95f7435f6e4f9ed": {"t": 1785753944.5076404, "n": 25196, "h": "bc504188462cd7f2", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { buildMapRuntimeView } = require(\"../src/game/mapRuntimeView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction buildMapRuntimeDraftContext(worldState = {}, entityType = \"military_report\") {\n const view = buildMapRuntimeView(worldState);\n const ref = view.refs.find((entry) => entry.entityType === entityType);\n assert.ok(ref, `expected a safe ${entityType} map runtime ref`);\n const [draftId] = Object.entries(view.actionDrafts || {}).find(([, draft]) => draft.targetRef === ref.mapEntityRef) || [];\n assert.ok(draftId, `expected a safe ${entityType} map runtime action draft`);\n return {\n surfaceId: \"map-runtime\",\n draftKind: \"map_ref_action\",\n sourceView: \"mapRuntimeView\",\n draftId,\n evidenceRefs: [ref.mapEntityRef, ref.sourceRef, \"map:forged:secret\", \"layoutPath\", \"mapBounds:0:1\"],\n sourceRefs: [ref.sourceRef, \"viewportHint\", \"x:0.5\", \"domainConsequenceView:visual-only\"],\n targetRefs: [ref.mapEntityRef, \"providerPayload\"],\n actionDraftRefs: [draftId, \"draft-forged-secret\"],\n requiresServerTurn: true,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.10 true provider stream revalidates map-runtime military draftContext\", async (t) => {\n const payload = makeTurnPayload(\"军议舆图草稿已随流式回合复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"general\", playerName: \"流式舆图将领\" });\n const draftContext = buildMapRuntimeDraftContext(worldState, \"military_report\");\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"据舆图军议,遣哨侦察边报与粮道虚实,先呈公开军情。\";\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const saved = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({\n roleCycleDomainAdjudication: finalState?.data?.roleCycleDomainAdjudication,\n worldState: finalState?.data?.worldState,\n attributeChanges: finalState?.data?.attributeChanges,\n relationshipChanges: finalState?.data?.relationshipChanges\n });\n const outcome = finalState?.data?.roleCycleDomainAdjudication?.outcome;\n const mapContext = outcome?.mapRuntimeDraftContext;\n\n assert.equal(runTurnCalls, 0);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(outcome.status, \"accepted\");\n assert.equal(outcome.resolver, \"military_diplomacy\");\n assert.equal(outcome.intent, \"scout\");\n assert.equal(mapContext.surfaceId, \"map-runtime\");\n assert.equal(mapContext.status, \"verified\");\n assert.deepEqual(mapContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(mapContext.actionDraftRefs, [draftContext.draftId]);\n assert.equal(mapContext.evidenceRefs.includes(\"map:forged:secret\"), false);\n assert.equal(finalState.data.worldState?.militaryDiplomacyLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /map:forged:secret|draft-forged-secret|layoutPath|mapBounds|viewportHint|providerPayload|domainConsequenceView:visual-only|\"militaryDiplomacyLedger\":|\"cityPolicyLedger\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|rawSql|SEALED_/i\n );\n assert.equal(saved.militaryDiplomacyLedger.records.length, 1);\n assert.equal(saved.militaryDiplomacyLedger.records[0].actionKind, \"scout\");\n assert.equal(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.surfaceId, \"map-runtime\");\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.targetRefs, draftContext.targetRefs.slice(0, 1));\n assert.deepEqual(saved.militaryDiplomacyLedger.records[0].mapRuntimeDraftContext.actionDraftRefs, [draftContext.draftId]);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "53dc1f3d3bd3b57d1e065b947305f579a1ce4686": {"t": 1785753945.8798788, "n": 20894, "h": "1ac963324eae4ba4", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nfunction createRealProviderTestServer() {\n delete require.cache[gameRoutePath];\n const gameRoutes = require(gameRoutePath);\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n}\n\nfunction restoreEnv(previous = {}) {\n for (const [key, value] of Object.entries(previous)) {\n if (value === undefined) delete process.env[key];\n else process.env[key] = value;\n }\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftDidNotSettleBusinessLedgers(savedBefore = {}, savedAfter = {}) {\n // 无 key Mock 回退仍会推进普通维护;这里单独隔离经济草稿可能越权结算的账本面。\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.npcEconomyRecentEvents.slice(0, before.npcEconomyRecentEvents.length), before.npcEconomyRecentEvents);\n assert.doesNotMatch(\n JSON.stringify(after.npcEconomyRecentEvents.slice(before.npcEconomyRecentEvents.length)),\n /economyTraceView|domainConsequenceEcho|forged|draftContext|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/i\n );\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"S88.12 no-key real provider falls back to Mock for economy draft JSON and SSE turns\", async (t) => {\n const previous = {\n AI_PROVIDER: process.env.AI_PROVIDER,\n DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY\n };\n process.env.AI_PROVIDER = \"deepseek\";\n delete process.env.DEEPSEEK_API_KEY;\n\n const server = createRealProviderTestServer();\n t.after(() => {\n restoreEnv(previous);\n delete require.cache[gameRoutePath];\n return server.close();\n });\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"无钥回退知县\" });\n seedEconomyTopicSignals(worldState);\n worldState.delegatedTaskLedger = {\n ...(worldState.delegatedTaskLedger || {}),\n tasks: []\n };\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const second = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input,\n draftContext\n });\n const savedAfterJsonDraft = await readSession(worldState.sessionId);\n const sseDraftContext = buildEconomyDraftContext(savedAfterJsonDraft);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, sseDraftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSseDraft = await readSession(worldState.sessionId);\n const serialized = JSON.stringify({ first, second, finalState });\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assertEconomyDraftResponseSafe(second);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterFirst, savedAfterJsonDraft);\n assert.ok(narrative.length > 0);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettleBusinessLedgers(savedAfterJsonDraft, savedAfterSseDraft);\n assert.doesNotMatch(serialized, /DEEPSEEK_API_KEY|DeepSeek requires|raw provider payload|data\\/sessions|sk-[A-Za-z0-9_-]{6,}/i);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "f533d1c104a89edceae7ac3c75965c1ab85d82c5": {"t": 1785753948.238247, "n": 17097, "h": "c4259064a21565d2", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { createLandSurveyDelegatedTask } = require(\"../src/game/delegatedTasks\");\nconst { resolveTradeRequest } = require(\"../src/game/tradeLedger\");\nconst { buildTopicSurfaceView } = require(\"../src/game/topicSurfaceView\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst auditDir = path.join(__dirname, \"..\", \"data\", \"audit\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n await fs.rm(path.join(sessionsDir, `${sessionId}.lock`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.event-log.jsonl`), { force: true });\n await fs.rm(path.join(auditDir, `${sessionId}.ai-proposals.jsonl`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nasync function postTurnJson(baseUrl, body) {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n return response.json();\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\", draftContext = undefined) {\n const body = { sessionId, input };\n if (draftContext) {\n body.draftContext = draftContext;\n }\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify(body)\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\nfunction seedEconomyTopicSignals(worldState = {}) {\n worldState.year = 1644;\n worldState.month = 4;\n worldState.tenDayPeriod = 1;\n worldState.turnCount = Math.max(Number(worldState.turnCount) || 0, 19);\n worldState.player.localTreasury = 120;\n worldState.npcEconomyLedger = {\n ...(worldState.npcEconomyLedger || {}),\n recentEvents: [\n \"人情债月账:韩员外为修桥垫付,公开人情债略增。\",\n \"provider payload hiddenNotes data/sessions/secret.json\"\n ]\n };\n resolveTradeRequest(worldState, {\n npcId: \"npc:magistrate:gentry-han\",\n tradeId: \"trade:stream:draft-economy\",\n silverDelta: 0,\n offerSummary: \"询问纸张与粟米行价。\"\n }, {\n npcResponse: \"可再议。\",\n proposal: {\n status: \"countered\",\n publicSummary: \"韩员外交易议价:纸张与粮价消息尚待服务器确认。\",\n riskTags: [\"议价\"]\n }\n });\n const delegated = createLandSurveyDelegatedTask(worldState, {\n assigneeActorId: \"npc:magistrate:registrar-lu\",\n targetRef: \"geo:county:qinghe:east-village\",\n commandText: \"丈量东乡田亩,核对鱼鳞册与实耕。\",\n budget: 24\n });\n assert.equal(delegated.ok, true);\n}\n\nfunction buildEconomyDraftContext(worldState = {}) {\n const topicView = buildTopicSurfaceView(worldState, { surfaceId: \"memorial-review\" });\n const economyRef = topicView.evidenceRefs.find((ref) => ref.sourceView === \"economyTraceView\");\n assert.ok(economyRef, \"expected a safe economyTraceView topic evidence ref\");\n assert.deepEqual(economyRef.canonicalEchoRefs || [], []);\n return {\n surfaceId: \"memorial-review\",\n draftKind: topicView.draftSlots[0]?.draftKind || \"vermilion_comment\",\n evidenceRefs: [economyRef.refId, \"economyTraceView:forged-secret\"],\n canonicalEchoRefs: [\"domainConsequenceEcho:forged\"],\n generatedAtTurn: topicView.generatedAtTurn,\n status: \"client_hint\"\n };\n}\n\nfunction snapshotEconomyLedgers(worldState = {}) {\n return {\n cityPolicyCount: worldState.cityPolicyLedger?.records?.length || 0,\n militaryDiplomacyCount: worldState.militaryDiplomacyLedger?.records?.length || 0,\n tradeRecords: JSON.parse(JSON.stringify(worldState.tradeLedger?.records || [])),\n delegatedTasks: JSON.parse(JSON.stringify(worldState.delegatedTaskLedger?.tasks || [])),\n resourceLedger: JSON.parse(JSON.stringify(worldState.resourceLedger || null)),\n npcEconomyRecentEvents: [...(worldState.npcEconomyLedger?.recentEvents || [])],\n playerGold: worldState.player?.gold,\n localTreasury: worldState.player?.localTreasury\n };\n}\n\nfunction assertEconomyDraftDidNotSettle(savedBefore = {}, savedAfter = {}) {\n const before = snapshotEconomyLedgers(savedBefore);\n const after = snapshotEconomyLedgers(savedAfter);\n assert.equal(after.cityPolicyCount, before.cityPolicyCount);\n assert.equal(after.militaryDiplomacyCount, before.militaryDiplomacyCount);\n assert.deepEqual(after.tradeRecords, before.tradeRecords);\n assert.deepEqual(after.delegatedTasks, before.delegatedTasks);\n assert.deepEqual(after.resourceLedger, before.resourceLedger);\n assert.deepEqual(after.npcEconomyRecentEvents, before.npcEconomyRecentEvents);\n assert.equal(after.playerGold, before.playerGold);\n assert.equal(after.localTreasury, before.localTreasury);\n}\n\nfunction assertEconomyDraftResponseSafe(payload = {}) {\n const serialized = JSON.stringify(payload);\n const relationshipChangesText = JSON.stringify(payload.relationshipChanges || []);\n const outcome = payload.roleCycleDomainAdjudication?.outcome;\n assert.equal(outcome?.status, \"duplicate_recent\");\n assert.equal(outcome?.resolver, \"city_policy\");\n assert.equal(outcome?.canonicalEchoRefs, undefined);\n assert.equal(outcome?.topicDraftContext, undefined);\n assert.doesNotMatch(relationshipChangesText, /economyTraceView|tradeLedger|delegatedTaskLedger|resourceDelta|relationshipSignals|账解|交易|委派|人情债/);\n assert.equal(payload.worldState?.cityPolicyLedger, undefined);\n assert.equal(payload.worldState?.militaryDiplomacyLedger, undefined);\n assert.equal(payload.worldState?.resourceLedger, undefined);\n assert.doesNotMatch(\n serialized,\n /domainConsequenceEcho:forged|economyTraceView:forged-secret|\"tradeLedger\":|\"delegatedTaskLedger\":|\"marketPriceLedger\":|\"npcEconomyLedger\":|\"resourceDelta\":|\"relationshipSignals\":|\"stateDelta\":|\"playerDelta\":|\"auditRecord\":|provider payload|hiddenNotes|data\\/sessions|rawSql|SEALED_/\n );\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"S88.8 true provider stream revalidates economy draftContext without settling economy ledgers\", async (t) => {\n const payload = makeTurnPayload(\"账解已随流式草稿复核。\");\n let runTurnCalls = 0;\n let streamTurnCalls = 0;\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n streamTurnCalls += 1;\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 8) {\n handlers.onTextDelta(raw.slice(index, index + 8));\n }\n return payload;\n },\n async runTurn() {\n runTurnCalls += 1;\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"账解真流知县\" });\n seedEconomyTopicSignals(worldState);\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const input = \"本旬先处置广州粮储市价:核公开材料、经手人、期限和需回报事项。\";\n const first = await postTurnJson(server.baseUrl, {\n sessionId: worldState.sessionId,\n input\n });\n const savedAfterFirst = await readSession(worldState.sessionId);\n const draftContext = buildEconomyDraftContext(savedAfterFirst);\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, input, draftContext);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const savedAfterSecond = await readSession(worldState.sessionId);\n\n assert.equal(first.roleCycleDomainAdjudication.outcome.status, \"accepted\");\n assert.equal(runTurnCalls, 1);\n assert.equal(streamTurnCalls, 1);\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assertEconomyDraftResponseSafe(finalState.data);\n assertEconomyDraftDidNotSettle(savedAfterFirst, savedAfterSecond);\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "cf1abbfd564b83f8267b4e6b9f560495909db612": {"t": 1785753949.4374387, "n": 9955, "h": "af7c2aae69ae719f", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\") {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify({ sessionId, input })\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.aiControlAuditView);\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.ok(preview);\n assert.equal(preview.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.aiControlAuditView.schemaVersion, \"s71.11-ai-control-audit.v1\");\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "54afc384cb81f2cddae7a904d220f71ab9cf2cb9": {"t": 1785753950.6474042, "n": 9635, "h": "4da93f3fa0614098", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nfunction makeWritingExam() {\n return {\n examId: \"child_exam-streaming-scene\",\n level: \"child_exam\",\n examName: \"童试\",\n examQuestion: \"试论修身读书与县学教化之要。\",\n questionType: \"经义简答\",\n status: \"writing\"\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\") {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify({ sessionId, input })\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn streams local exam scene actions without global time tick\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn() {\n throw new Error(\"provider should not run during active exam scene\");\n },\n async runTurn() {\n throw new Error(\"provider fallback should not run during active exam scene\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ExamSceneStream\" });\n worldState.year = 1644;\n worldState.month = 1;\n worldState.tenDayPeriod = 3;\n worldState.turnCount = 6;\n worldState.activeExam = makeWritingExam();\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId, \"拟纲定章法\");\n const finalState = events.find((event) => event.event === \"final_state\");\n const preview = events.find((event) => event.event === \"state_preview\" && event.data?.worldTick);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.match(narrative, /童试/);\n assert.ok(preview);\n assert.equal(preview.data.worldTick.cadence, \"scene\");\n assert.equal(finalState.data.worldState.turnCount, 6);\n assert.equal(finalState.data.worldState.month, 1);\n assert.equal(finalState.data.worldState.tenDayPeriod, 3);\n assert.equal(finalState.data.worldState.activeExam.sceneTime.phase, \"outline\");\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 6);\n assert.equal(saved.tenDayPeriod, 3);\n assert.equal(saved.activeExam.sceneTime.phase, \"outline\");\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "b3442173da156f29e33fe34123733ef2bfb1046a": {"t": 1785753952.1618817, "n": 7687, "h": "b4e3701c1fd92a5d", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\") {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify({ sessionId, input })\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn ignores nested streamed narrative fields\", async (t) => {\n const payload = makeTurnPayload(\"顶层叙事方可入史。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const streamed = JSON.stringify({\n statePatch: {\n narrative: \"嵌套叙事不可外显。\",\n player: {\n academia: 13\n }\n },\n attributeChanges: payload.attributeChanges,\n relationshipChanges: [],\n events: payload.events,\n examTrigger: payload.examTrigger,\n narrative: payload.narrative\n });\n for (let index = 0; index < streamed.length; index += 9) {\n handlers.onTextDelta(streamed.slice(index, index + 9));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"NestedStream\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.equal(narrative.includes(\"嵌套叙事不可外显\"), false);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "a38cf342f03a969e751cb2c0163326e218bfdaff": {"t": 1785753954.3446028, "n": 6184, "h": "ee58b52123bc3e2b", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\nconst { createFetchSafeServer } = require(\"../test-helpers/fetchSafeServer\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n return createFetchSafeServer(app);\n });\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\") {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify({ sessionId, input })\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}, "fe66b814d67bd6a9d18937d0b7a2bcc4fb9eb568": {"t": 1785753955.6191761, "n": 6335, "h": "56041e4a55dea249", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\nconst express = require(\"express\");\nconst fs = require(\"node:fs/promises\");\nconst path = require(\"node:path\");\n\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst { readSession, writeSession } = require(\"../src/storage/sessionStore\");\n\nconst sessionsDir = path.join(__dirname, \"..\", \"data\", \"sessions\");\nconst gameRoutePath = require.resolve(\"../src/routes/game\");\nconst aiPath = require.resolve(\"../src/ai\");\n\nfunction makeTurnPayload(narrative = \"松风入砚,书声稍定。\") {\n return {\n narrative,\n statePatch: {\n player: {\n academia: 13\n }\n },\n attributeChanges: [\n {\n path: \"player.academia\",\n before: 10,\n after: 13,\n reason: \"读书有得\"\n }\n ],\n relationshipChanges: [],\n events: [\"书窗夜读,稍有所得。\"],\n examTrigger: {\n shouldStart: false,\n level: null,\n reason: \"\"\n }\n };\n}\n\nasync function removeSessionFile(sessionId) {\n await fs.rm(path.join(sessionsDir, `${sessionId}.json`), { force: true });\n}\n\nfunction parseSse(text) {\n return text\n .replace(/\\r\\n/g, \"\\n\")\n .split(\"\\n\\n\")\n .filter((block) => block.trim())\n .map((block) => {\n const lines = block.split(\"\\n\");\n const eventLine = lines.find((line) => line.startsWith(\"event:\"));\n const data = lines\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).replace(/^ /, \"\"))\n .join(\"\\n\");\n\n return {\n event: eventLine ? eventLine.slice(6).trim() : \"message\",\n data: data ? JSON.parse(data) : null\n };\n });\n}\n\nfunction withProvider(provider, callback) {\n const aiModule = require(aiPath);\n const originalGetProvider = aiModule.getProvider;\n aiModule.getProvider = () => provider;\n delete require.cache[gameRoutePath];\n\n try {\n return callback(require(gameRoutePath));\n } finally {\n aiModule.getProvider = originalGetProvider;\n delete require.cache[gameRoutePath];\n }\n}\n\nfunction createTestServer(provider) {\n return withProvider(provider, (gameRoutes) => {\n const app = express();\n app.use(express.json());\n app.use(\"/api/game\", gameRoutes);\n\n const server = app.listen(0);\n const { port } = server.address();\n\n return {\n baseUrl: `http://127.0.0.1:${port}`,\n close: () => new Promise((resolve, reject) => {\n server.close((error) => error ? reject(error) : resolve());\n })\n };\n });\n}\n\nasync function postTurnSse(baseUrl, sessionId, input = \"读书\") {\n const response = await fetch(`${baseUrl}/api/game/turn`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\"\n },\n body: JSON.stringify({ sessionId, input })\n });\n\n assert.equal(response.status, 200);\n assert.match(response.headers.get(\"content-type\") || \"\", /text\\/event-stream/);\n return parseSse(await response.text());\n}\n\ntest(\"POST /api/game/turn streams extracted provider narrative before final_state\", async (t) => {\n const payload = makeTurnPayload(\"松风入砚,问学渐明。\");\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n const raw = JSON.stringify(payload);\n for (let index = 0; index < raw.length; index += 7) {\n handlers.onTextDelta(raw.slice(index, index + 7));\n }\n return payload;\n },\n async runTurn() {\n throw new Error(\"runTurn should not be used when streaming succeeds\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Streamer\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(finalState);\n assert.equal(finalState.data.worldState.turnCount, 1);\n assert.equal(finalState.data.worldState.player.academia, 13);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 1);\n assert.equal(saved.player.academia, 13);\n});\n\ntest(\"POST /api/game/turn preserves SSE fallback when provider has no stream method\", async (t) => {\n const payload = makeTurnPayload(\"案头灯火未歇,心志稍坚。\");\n const provider = {\n supportsStreaming: false,\n async runTurn() {\n return payload;\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"Fallback\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const narrative = events\n .filter((event) => event.event === \"narrative_chunk\")\n .map((event) => event.data.text)\n .join(\"\");\n\n assert.equal(narrative, payload.narrative);\n assert.ok(events.find((event) => event.event === \"final_state\"));\n});\n\ntest(\"POST /api/game/turn emits error and does not mutate state after visible stream failure\", async (t) => {\n const provider = {\n supportsStreaming: true,\n async streamTurn(worldState, input, handlers) {\n handlers.onTextDelta('{\"narrative\":\"半卷未终');\n throw new Error(\"stream schema failed\");\n },\n async runTurn() {\n throw new Error(\"runTurn fallback should not happen after visible streaming\");\n }\n };\n const server = createTestServer(provider);\n t.after(server.close);\n\n const worldState = createInitialState({ playerName: \"ErrorCase\" });\n t.after(() => removeSessionFile(worldState.sessionId));\n await writeSession(worldState);\n\n const events = await postTurnSse(server.baseUrl, worldState.sessionId);\n const errorEvent = events.find((event) => event.event === \"error\");\n const finalState = events.find((event) => event.event === \"final_state\");\n\n assert.ok(errorEvent);\n assert.match(errorEvent.data.error, /stream schema failed/);\n assert.equal(finalState, undefined);\n\n const saved = await readSession(worldState.sessionId);\n assert.equal(saved.turnCount, 0);\n assert.equal(saved.player.academia, 10);\n});\n"}}, "test/judicialCaseEvidenceRedaction.test.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745544.3739283, "n": 5088, "h": "191eda82f6930bc8", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\n\nconst { buildPlayerAiActorProfile } = require(\"../src/game/aiActorProfiles\");\nconst {\n applyJudicialCaseOutcome,\n normalizeCaseProposal,\n resolveJudicialCase\n} = require(\"../src/game/judicialCaseResolver\");\nconst { collectVisibleDomainEvidenceRefs } = require(\"../src/game/domainToolResolvers\");\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n assertRedactedStateSafe,\n buildPlayerStateEnvelope\n} = require(\"../src/game/redactedState\");\n\nfunction scopedDocket(worldState, actorProfile) {\n const actorScopes = new Set(actorProfile.jurisdictionRefs || []);\n const entry = [...collectVisibleDomainEvidenceRefs(worldState, actorProfile).values()].find((item) =>\n item.domain === \"local_docket\" &&\n (item.scopeRefs || []).some((scopeRef) => actorScopes.has(scopeRef))\n );\n assert.ok(entry, \"缺少辖区案牍证据\");\n return entry;\n}\n\nfunction peopleEvidence(worldState, actorProfile) {\n const entry = [...collectVisibleDomainEvidenceRefs(worldState, actorProfile).values()]\n .find((item) => item.domain === \"people\");\n assert.ok(entry, \"缺少可见人物证据\");\n return entry;\n}\n\ntest(\"S71.6 judicial normalizer strips hidden/raw text and private result refs\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"慎刑知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const normalized = normalizeCaseProposal({\n caseAction: \"judge\",\n publicSummary: \"SEALED_CASE hiddenNotes rawSql /mnt/e/LSMNQ/data/sessions/secret.json sk-test-secret\",\n evidenceRefs: [\"local_docket:test\"],\n privateResultRefs: [\"hidden:evidence\"],\n statePatch: { publicOrder: 999 }\n }, { actorProfile });\n const payload = JSON.stringify(normalized);\n\n assert.equal(normalized.publicSummary, \"判决待服务器裁决。\");\n assert.deepEqual(normalized.privateResultRefs, []);\n assert.equal(normalized.safetyFlags.length > 0, true);\n assert.doesNotMatch(payload, /SEALED_CASE|hiddenNotes|rawSql|statePatch|sk-test-secret|\\/mnt\\/e\\/LSMNQ/);\n});\n\ntest(\"S71.6 polluted judicial outcome rejects and keeps audit hidden-safe\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"风宪知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const docket = scopedDocket(worldState, actorProfile);\n const person = peopleEvidence(worldState, actorProfile);\n const outcome = resolveJudicialCase(worldState, {\n caseAction: \"judge\",\n evidenceRefs: [docket.ref, person.ref],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"SEALED_CASE provider proposal prompt_retrieval_index data/sessions/secret.json\",\n worldState: { publicOrder: 999 },\n rawSql: \"update world_sessions set world_state_json='SEALED'\"\n }, { actorProfile });\n const payload = JSON.stringify(outcome);\n\n assert.equal(outcome.status, \"rejected\");\n assert.match(outcome.rejectionReasons.join(\" \"), /禁止来源|隐藏证据|直写字段/);\n assert.equal(outcome.auditRecord.safety.proposalPayloadIncluded, false);\n assert.equal(outcome.auditRecord.safety.stateSnapshotIncluded, false);\n assert.equal(outcome.auditRecord.safety.sqlIncluded, false);\n assert.doesNotMatch(\n payload,\n /SEALED_CASE|prompt_retrieval_index|world_sessions|world_state_json|data\\/sessions|rawSql|statePatch|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/\n );\n});\n\ntest(\"S71.6 accepted judicial ledger stays out of redacted player state and public docket hides evidence refs\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"明刑知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const docket = scopedDocket(worldState, actorProfile);\n const person = peopleEvidence(worldState, actorProfile);\n const outcome = resolveJudicialCase(worldState, {\n caseAction: \"judge\",\n evidenceRefs: [docket.ref, person.ref],\n targetRefs: [person.ref],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"据辖区案牍与人证判决。\",\n caseSeverity: 3\n }, { actorProfile });\n\n assert.equal(outcome.status, \"accepted\");\n assert.equal(JSON.stringify(outcome.publicDocket).includes(docket.ref), false);\n assert.equal(JSON.stringify(outcome.publicDocket).includes(docket.id), false);\n assert.equal(JSON.stringify(outcome.publicDocket).includes(person.id), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(docket.ref), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(docket.id), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(person.id), false);\n applyJudicialCaseOutcome(worldState, outcome);\n assert.equal(worldState.judicialCaseLedger.records.length, 1);\n\n const envelope = buildPlayerStateEnvelope(worldState);\n const payload = JSON.stringify(envelope);\n\n assert.equal(envelope.worldState.judicialCaseLedger, undefined);\n assert.equal(envelope.worldState.eventHistory.length > 0, true);\n assert.doesNotMatch(payload, /judicialCaseLedger|auditRecord|rawSql|statePatch|SEALED_CASE/);\n assertRedactedStateSafe(envelope);\n});\n"}, "4d93b2d7009cb9fda7c1d3f82c13b84f322635a0": {"t": 1785753930.3207543, "n": 5088, "h": "191eda82f6930bc8", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\n\nconst { buildPlayerAiActorProfile } = require(\"../src/game/aiActorProfiles\");\nconst {\n applyJudicialCaseOutcome,\n normalizeCaseProposal,\n resolveJudicialCase\n} = require(\"../src/game/judicialCaseResolver\");\nconst { collectVisibleDomainEvidenceRefs } = require(\"../src/game/domainToolResolvers\");\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n assertRedactedStateSafe,\n buildPlayerStateEnvelope\n} = require(\"../src/game/redactedState\");\n\nfunction scopedDocket(worldState, actorProfile) {\n const actorScopes = new Set(actorProfile.jurisdictionRefs || []);\n const entry = [...collectVisibleDomainEvidenceRefs(worldState, actorProfile).values()].find((item) =>\n item.domain === \"local_docket\" &&\n (item.scopeRefs || []).some((scopeRef) => actorScopes.has(scopeRef))\n );\n assert.ok(entry, \"缺少辖区案牍证据\");\n return entry;\n}\n\nfunction peopleEvidence(worldState, actorProfile) {\n const entry = [...collectVisibleDomainEvidenceRefs(worldState, actorProfile).values()]\n .find((item) => item.domain === \"people\");\n assert.ok(entry, \"缺少可见人物证据\");\n return entry;\n}\n\ntest(\"S71.6 judicial normalizer strips hidden/raw text and private result refs\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"慎刑知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const normalized = normalizeCaseProposal({\n caseAction: \"judge\",\n publicSummary: \"SEALED_CASE hiddenNotes rawSql /mnt/e/LSMNQ/data/sessions/secret.json sk-test-secret\",\n evidenceRefs: [\"local_docket:test\"],\n privateResultRefs: [\"hidden:evidence\"],\n statePatch: { publicOrder: 999 }\n }, { actorProfile });\n const payload = JSON.stringify(normalized);\n\n assert.equal(normalized.publicSummary, \"判决待服务器裁决。\");\n assert.deepEqual(normalized.privateResultRefs, []);\n assert.equal(normalized.safetyFlags.length > 0, true);\n assert.doesNotMatch(payload, /SEALED_CASE|hiddenNotes|rawSql|statePatch|sk-test-secret|\\/mnt\\/e\\/LSMNQ/);\n});\n\ntest(\"S71.6 polluted judicial outcome rejects and keeps audit hidden-safe\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"风宪知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const docket = scopedDocket(worldState, actorProfile);\n const person = peopleEvidence(worldState, actorProfile);\n const outcome = resolveJudicialCase(worldState, {\n caseAction: \"judge\",\n evidenceRefs: [docket.ref, person.ref],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"SEALED_CASE provider proposal prompt_retrieval_index data/sessions/secret.json\",\n worldState: { publicOrder: 999 },\n rawSql: \"update world_sessions set world_state_json='SEALED'\"\n }, { actorProfile });\n const payload = JSON.stringify(outcome);\n\n assert.equal(outcome.status, \"rejected\");\n assert.match(outcome.rejectionReasons.join(\" \"), /禁止来源|隐藏证据|直写字段/);\n assert.equal(outcome.auditRecord.safety.proposalPayloadIncluded, false);\n assert.equal(outcome.auditRecord.safety.stateSnapshotIncluded, false);\n assert.equal(outcome.auditRecord.safety.sqlIncluded, false);\n assert.doesNotMatch(\n payload,\n /SEALED_CASE|prompt_retrieval_index|world_sessions|world_state_json|data\\/sessions|rawSql|statePatch|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/\n );\n});\n\ntest(\"S71.6 accepted judicial ledger stays out of redacted player state and public docket hides evidence refs\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"明刑知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const docket = scopedDocket(worldState, actorProfile);\n const person = peopleEvidence(worldState, actorProfile);\n const outcome = resolveJudicialCase(worldState, {\n caseAction: \"judge\",\n evidenceRefs: [docket.ref, person.ref],\n targetRefs: [person.ref],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"据辖区案牍与人证判决。\",\n caseSeverity: 3\n }, { actorProfile });\n\n assert.equal(outcome.status, \"accepted\");\n assert.equal(JSON.stringify(outcome.publicDocket).includes(docket.ref), false);\n assert.equal(JSON.stringify(outcome.publicDocket).includes(docket.id), false);\n assert.equal(JSON.stringify(outcome.publicDocket).includes(person.id), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(docket.ref), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(docket.id), false);\n assert.equal(JSON.stringify(outcome.publicEvent).includes(person.id), false);\n applyJudicialCaseOutcome(worldState, outcome);\n assert.equal(worldState.judicialCaseLedger.records.length, 1);\n\n const envelope = buildPlayerStateEnvelope(worldState);\n const payload = JSON.stringify(envelope);\n\n assert.equal(envelope.worldState.judicialCaseLedger, undefined);\n assert.equal(envelope.worldState.eventHistory.length > 0, true);\n assert.doesNotMatch(payload, /judicialCaseLedger|auditRecord|rawSql|statePatch|SEALED_CASE/);\n assertRedactedStateSafe(envelope);\n});\n"}}, "src/game/eventArchive.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745547.7769144, "n": 40729, "h": "0d5fba340cc3ee66", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\nconst { buildWorldEntityView } = require(\"./worldEntities\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_WORLD_ENTITY_IMPACT_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent|dossier)|hidden\\s+(?:notes?|intent|dossier)|hiddenDossier|hidden_dossier|privateSignalTags|private_signal_tags|providerPayload|provider_payload|trueAssets|true_assets|secretRelationships|secret_relationships|unrevealedTasks|unrevealed_tasks|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|retrieval_context|sealedMapping|sealed_mapping|safe_search_index|safe_search_fts|state_patch|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n world_entity_impact: \"实体压力\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n consequence.targetLabel,\n consequence.cooldownSummary,\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.sideEffects) ? consequence.sideEffects.map((item) => item.label || item.publicSummary) : []),\n ...(Array.isArray(consequence.longTermRisks) ? consequence.longTermRisks.map((item) => item.label || item.publicSummary) : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectWorldEntityImpactItems(worldState, items, worldEntityView) {\n const impacts = Array.isArray(worldEntityView?.recentImpacts)\n ? worldEntityView.recentImpacts\n : [];\n impacts.slice(-MAX_WORLD_ENTITY_IMPACT_RECORDS).forEach((impact) => {\n addItem(items, worldState, {\n sourceType: \"world_entity_impact\",\n kind: impact.sourceType || \"world_entity_impact\",\n title: impact.title || `${impact.entityName || \"实体\"}压力留痕`,\n summary: impact.publicSummary,\n date: impact,\n turn: impact.generatedAtTurn,\n status: impact.status === \"stable\" ? \"recorded\" : \"watch\",\n riskLabel: impact.riskLabel || impact.statusLabel || \"\",\n relatedLabels: [\n impact.entityName,\n impact.sourceLabel,\n impact.statusLabel,\n ...(Array.isArray(impact.affectedMetricLabels) ? impact.affectedMetricLabels : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const followUp = isPlainObject(record.outcome.followUpView) ? record.outcome.followUpView : {};\n const latestResolution = isPlainObject(followUp.latestResolution)\n ? followUp.latestResolution\n : Array.isArray(record.outcome.followUpResolutions) && record.outcome.followUpResolutions.length\n ? record.outcome.followUpResolutions[record.outcome.followUpResolutions.length - 1]\n : null;\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const outcomeSummary = cleanArchiveText(record.outcome.publicSummary, \"\", 140);\n const followUpSummary = cleanArchiveText(\n latestResolution?.publicSummary || latestResolution?.nextStep || followUp.publicSummary || followUp.nextStep,\n \"\",\n 140\n );\n const summary = cleanArchiveText(\n [outcomeSummary, followUpSummary].filter(Boolean).join(\";\"),\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: latestResolution?.createdTurn ?? record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: latestResolution?.followUpKind || latestResolution?.statusLabel || followUp.followUpKind || trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n latestResolution?.statusLabel,\n latestResolution?.taskRouteLabel,\n followUp.title,\n followUp.taskState,\n trace.responseAction,\n ...(Array.isArray(latestResolution?.riskTags) ? latestResolution.riskTags : []),\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(followUp.riskTags) ? followUp.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const worldEntityView = buildWorldEntityView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectWorldEntityImpactItems(worldState, items, worldEntityView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "f92c3e7193b37f244ca783569f840739a3bee8a8": {"t": 1785753938.4559524, "n": 40729, "h": "0d5fba340cc3ee66", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\nconst { buildWorldEntityView } = require(\"./worldEntities\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_WORLD_ENTITY_IMPACT_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent|dossier)|hidden\\s+(?:notes?|intent|dossier)|hiddenDossier|hidden_dossier|privateSignalTags|private_signal_tags|providerPayload|provider_payload|trueAssets|true_assets|secretRelationships|secret_relationships|unrevealedTasks|unrevealed_tasks|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|retrieval_context|sealedMapping|sealed_mapping|safe_search_index|safe_search_fts|state_patch|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n world_entity_impact: \"实体压力\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n consequence.targetLabel,\n consequence.cooldownSummary,\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.sideEffects) ? consequence.sideEffects.map((item) => item.label || item.publicSummary) : []),\n ...(Array.isArray(consequence.longTermRisks) ? consequence.longTermRisks.map((item) => item.label || item.publicSummary) : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectWorldEntityImpactItems(worldState, items, worldEntityView) {\n const impacts = Array.isArray(worldEntityView?.recentImpacts)\n ? worldEntityView.recentImpacts\n : [];\n impacts.slice(-MAX_WORLD_ENTITY_IMPACT_RECORDS).forEach((impact) => {\n addItem(items, worldState, {\n sourceType: \"world_entity_impact\",\n kind: impact.sourceType || \"world_entity_impact\",\n title: impact.title || `${impact.entityName || \"实体\"}压力留痕`,\n summary: impact.publicSummary,\n date: impact,\n turn: impact.generatedAtTurn,\n status: impact.status === \"stable\" ? \"recorded\" : \"watch\",\n riskLabel: impact.riskLabel || impact.statusLabel || \"\",\n relatedLabels: [\n impact.entityName,\n impact.sourceLabel,\n impact.statusLabel,\n ...(Array.isArray(impact.affectedMetricLabels) ? impact.affectedMetricLabels : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const followUp = isPlainObject(record.outcome.followUpView) ? record.outcome.followUpView : {};\n const latestResolution = isPlainObject(followUp.latestResolution)\n ? followUp.latestResolution\n : Array.isArray(record.outcome.followUpResolutions) && record.outcome.followUpResolutions.length\n ? record.outcome.followUpResolutions[record.outcome.followUpResolutions.length - 1]\n : null;\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const outcomeSummary = cleanArchiveText(record.outcome.publicSummary, \"\", 140);\n const followUpSummary = cleanArchiveText(\n latestResolution?.publicSummary || latestResolution?.nextStep || followUp.publicSummary || followUp.nextStep,\n \"\",\n 140\n );\n const summary = cleanArchiveText(\n [outcomeSummary, followUpSummary].filter(Boolean).join(\";\"),\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: latestResolution?.createdTurn ?? record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: latestResolution?.followUpKind || latestResolution?.statusLabel || followUp.followUpKind || trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n latestResolution?.statusLabel,\n latestResolution?.taskRouteLabel,\n followUp.title,\n followUp.taskState,\n trace.responseAction,\n ...(Array.isArray(latestResolution?.riskTags) ? latestResolution.riskTags : []),\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(followUp.riskTags) ? followUp.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const worldEntityView = buildWorldEntityView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectWorldEntityImpactItems(worldState, items, worldEntityView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "3d19a6be27dac1c254b3a46d603b97238135052e": {"t": 1785753940.0040886, "n": 40391, "h": "46fa0f2548042d0d", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\nconst { buildWorldEntityView } = require(\"./worldEntities\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_WORLD_ENTITY_IMPACT_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent|dossier)|hidden\\s+(?:notes?|intent|dossier)|hiddenDossier|hidden_dossier|privateSignalTags|private_signal_tags|providerPayload|provider_payload|trueAssets|true_assets|secretRelationships|secret_relationships|unrevealedTasks|unrevealed_tasks|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|retrieval_context|sealedMapping|sealed_mapping|safe_search_index|safe_search_fts|state_patch|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n world_entity_impact: \"实体压力\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectWorldEntityImpactItems(worldState, items, worldEntityView) {\n const impacts = Array.isArray(worldEntityView?.recentImpacts)\n ? worldEntityView.recentImpacts\n : [];\n impacts.slice(-MAX_WORLD_ENTITY_IMPACT_RECORDS).forEach((impact) => {\n addItem(items, worldState, {\n sourceType: \"world_entity_impact\",\n kind: impact.sourceType || \"world_entity_impact\",\n title: impact.title || `${impact.entityName || \"实体\"}压力留痕`,\n summary: impact.publicSummary,\n date: impact,\n turn: impact.generatedAtTurn,\n status: impact.status === \"stable\" ? \"recorded\" : \"watch\",\n riskLabel: impact.riskLabel || impact.statusLabel || \"\",\n relatedLabels: [\n impact.entityName,\n impact.sourceLabel,\n impact.statusLabel,\n ...(Array.isArray(impact.affectedMetricLabels) ? impact.affectedMetricLabels : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const followUp = isPlainObject(record.outcome.followUpView) ? record.outcome.followUpView : {};\n const latestResolution = isPlainObject(followUp.latestResolution)\n ? followUp.latestResolution\n : Array.isArray(record.outcome.followUpResolutions) && record.outcome.followUpResolutions.length\n ? record.outcome.followUpResolutions[record.outcome.followUpResolutions.length - 1]\n : null;\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const outcomeSummary = cleanArchiveText(record.outcome.publicSummary, \"\", 140);\n const followUpSummary = cleanArchiveText(\n latestResolution?.publicSummary || latestResolution?.nextStep || followUp.publicSummary || followUp.nextStep,\n \"\",\n 140\n );\n const summary = cleanArchiveText(\n [outcomeSummary, followUpSummary].filter(Boolean).join(\";\"),\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: latestResolution?.createdTurn ?? record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: latestResolution?.followUpKind || latestResolution?.statusLabel || followUp.followUpKind || trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n latestResolution?.statusLabel,\n latestResolution?.taskRouteLabel,\n followUp.title,\n followUp.taskState,\n trace.responseAction,\n ...(Array.isArray(latestResolution?.riskTags) ? latestResolution.riskTags : []),\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(followUp.riskTags) ? followUp.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const worldEntityView = buildWorldEntityView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectWorldEntityImpactItems(worldState, items, worldEntityView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "c08eb0fd2473b5552692a0e1491de66bc4a409a7": {"t": 1785753941.4514093, "n": 39230, "h": "965d9c276ac24d7b", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent|dossier)|hidden\\s+(?:notes?|intent|dossier)|hiddenDossier|hidden_dossier|privateSignalTags|private_signal_tags|providerPayload|provider_payload|trueAssets|true_assets|secretRelationships|secret_relationships|unrevealedTasks|unrevealed_tasks|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|retrieval_context|sealedMapping|sealed_mapping|safe_search_index|safe_search_fts|state_patch|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const followUp = isPlainObject(record.outcome.followUpView) ? record.outcome.followUpView : {};\n const latestResolution = isPlainObject(followUp.latestResolution)\n ? followUp.latestResolution\n : Array.isArray(record.outcome.followUpResolutions) && record.outcome.followUpResolutions.length\n ? record.outcome.followUpResolutions[record.outcome.followUpResolutions.length - 1]\n : null;\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const outcomeSummary = cleanArchiveText(record.outcome.publicSummary, \"\", 140);\n const followUpSummary = cleanArchiveText(\n latestResolution?.publicSummary || latestResolution?.nextStep || followUp.publicSummary || followUp.nextStep,\n \"\",\n 140\n );\n const summary = cleanArchiveText(\n [outcomeSummary, followUpSummary].filter(Boolean).join(\";\"),\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: latestResolution?.createdTurn ?? record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: latestResolution?.followUpKind || latestResolution?.statusLabel || followUp.followUpKind || trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n latestResolution?.statusLabel,\n latestResolution?.taskRouteLabel,\n followUp.title,\n followUp.taskState,\n trace.responseAction,\n ...(Array.isArray(latestResolution?.riskTags) ? latestResolution.riskTags : []),\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(followUp.riskTags) ? followUp.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "e5ab3417d7ff96a233286941c3ee91a87f5d0545": {"t": 1785753943.927423, "n": 38287, "h": "8153bcfdcf7f68ab", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const followUp = isPlainObject(record.outcome.followUpView) ? record.outcome.followUpView : {};\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const outcomeSummary = cleanArchiveText(record.outcome.publicSummary, \"\", 140);\n const followUpSummary = cleanArchiveText(\n followUp.publicSummary || followUp.nextStep,\n \"\",\n 140\n );\n const summary = cleanArchiveText(\n [outcomeSummary, followUpSummary].filter(Boolean).join(\";\"),\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: followUp.followUpKind || trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n followUp.title,\n followUp.taskState,\n trace.responseAction,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(followUp.riskTags) ? followUp.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "ad44a420c4717c75cd4af0c5f0934f894829993f": {"t": 1785753945.5200307, "n": 37779, "h": "f159981cc0b29159", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\nconst { buildNpcActiveRequestView } = require(\"./npcActiveRequests\");\nconst { buildNpcInteractionLedgerView } = require(\"./npcInteractions\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_NPC_ACTIVE_REQUEST_RECORDS = 6;\nconst MAX_NPC_RELATIONSHIP_ACTION_RECORDS = 6;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n npc_active_request: \"来函\",\n npc_relationship_action: \"交游\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectNpcActiveRequestItems(worldState, items, npcActiveRequestView) {\n const records = Array.isArray(npcActiveRequestView?.items) ? npcActiveRequestView.items : [];\n records\n .filter((record) => isPlainObject(record?.outcome?.resolverTrace))\n .filter((record) => record.outcome.resolverTrace.resolver === \"npc_active_request_resolver\")\n .slice(0, MAX_NPC_ACTIVE_REQUEST_RECORDS)\n .forEach((record) => {\n const trace = record.outcome.resolverTrace;\n const npcName = cleanArchiveText(record.npc?.displayName, \"来人\", 60);\n const typeLabel = cleanArchiveText(record.typeLabel || trace.typeLabel, \"来函\", 40);\n const summary = cleanArchiveText(\n record.outcome.publicSummary,\n `${npcName}的${typeLabel}已经服务器裁决。`\n );\n const watchStatuses = new Set([\n \"under_review\",\n \"reported\",\n \"converted_to_risk\",\n \"accepted_pending_server_resolution\"\n ]);\n addItem(items, worldState, {\n sourceType: \"npc_active_request\",\n sourceId: trace.publicResolutionRef || record.requestId,\n kind: trace.requestType || record.type || \"active_request\",\n title: `来函裁决:${npcName}${typeLabel}`,\n summary,\n turn: record.lastUpdatedTurn,\n status: watchStatuses.has(record.status) ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.status,\n relatedLabels: [\n npcName,\n typeLabel,\n trace.responseAction,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectNpcRelationshipActionItems(worldState, items, npcInteractionView) {\n const records = Array.isArray(npcInteractionView?.items) ? npcInteractionView.items : [];\n records\n .filter((record) => isPlainObject(record?.resolverTrace))\n .filter((record) => record.resolverTrace.resolver === \"npc_relationship_action_resolver\")\n .slice(0, MAX_NPC_RELATIONSHIP_ACTION_RECORDS)\n .forEach((record) => {\n const trace = record.resolverTrace;\n const npcName = cleanArchiveText(record.npcName, \"此人\", 60);\n const actionLabel = cleanArchiveText(\n trace.actionLabel || record.serverAdjudication?.actionLabel || record.actionKind || record.actionType,\n \"交游\",\n 40\n );\n const summary = cleanArchiveText(\n record.outcomeSummary || record.dialogueText,\n `${npcName}的${actionLabel}已由服务器裁决。`\n );\n addItem(items, worldState, {\n sourceType: \"npc_relationship_action\",\n sourceId: trace.publicResolutionRef || record.recordId,\n kind: trace.actionType || record.actionKind || record.actionType || \"npc_interaction\",\n title: `交游裁决:${npcName}${actionLabel}`,\n summary,\n date: record.date,\n turn: record.turn,\n status: trace.status === \"server_blocked\" ? \"watch\" : \"recorded\",\n riskLabel: trace.disposition || record.serverStatus,\n relatedLabels: [\n npcName,\n actionLabel,\n ...(Array.isArray(record.riskTags) ? record.riskTags : []),\n ...(Array.isArray(trace.riskTags) ? trace.riskTags : [])\n ]\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const npcActiveRequestView = buildNpcActiveRequestView(worldState, { includeResolved: true });\n const npcInteractionView = buildNpcInteractionLedgerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectNpcActiveRequestItems(worldState, items, npcActiveRequestView);\n collectNpcRelationshipActionItems(worldState, items, npcInteractionView);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "9a7a934bcd53ae10cb28c63013eda06a04eefc90": {"t": 1785753950.265478, "n": 33934, "h": "768407a4114268ae", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { collectDomainConsequenceEchoRefs } = require(\"./domainConsequenceEchoRefs\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction cleanArchiveId(value, fallback = \"\") {\n const text = cleanArchiveText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const sourceId = cleanArchiveId(fields.sourceId, \"\");\n const canonicalEchoRefs = collectDomainConsequenceEchoRefs(\n fields.canonicalEchoRefs,\n fields.canonicalEchoRef,\n fields.sourceId,\n fields.relatedLabels\n ).slice(0, 4);\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n const item = {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n if (sourceId) item.sourceId = sourceId;\n if (canonicalEchoRefs.length) item.canonicalEchoRefs = canonicalEchoRefs;\n return item;\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n const canonicalEchoRefs = thread.sourceType === \"domain_consequence\"\n ? collectDomainConsequenceEchoRefs(thread.sourceId, thread.id)\n : [];\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n sourceId: canonicalEchoRefs.length ? thread.sourceId : undefined,\n canonicalEchoRefs,\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n sourceId: consequence.publicEchoRef,\n canonicalEchoRefs: [consequence.publicEchoRef],\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "6943c7de31724e3eaf871230c32158c677449384": {"t": 1785753951.7198908, "n": 32680, "h": "5601a3af15917aa3", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildDomainConsequenceView } = require(\"./domainConsequenceTrace\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_DOMAIN_CONSEQUENCES = 6;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n domain_consequence: \"后果追踪\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectDomainConsequenceItems(worldState, items) {\n const domainConsequenceView = buildDomainConsequenceView(worldState);\n const consequences = Array.isArray(domainConsequenceView?.recentConsequences)\n ? domainConsequenceView.recentConsequences\n : [];\n consequences.slice(-MAX_DOMAIN_CONSEQUENCES).forEach((consequence) => {\n addItem(items, worldState, {\n sourceType: \"domain_consequence\",\n kind: consequence.sourceType || \"domain_consequence\",\n title: consequence.title || consequence.sourceLabel || \"领域后果追踪\",\n summary: consequence.publicSummary,\n date: consequence,\n turn: consequence.generatedAtTurn,\n status: consequence.severity >= 2 ? \"watch\" : \"recorded\",\n riskLabel: consequence.kindLabel || consequence.sourceLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(consequence.affectedMetricLabels) ? consequence.affectedMetricLabels : []),\n ...(Array.isArray(consequence.consequenceRefs) ? consequence.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectDomainConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "09f9f83b6696abb4141fee264c334b1ce4a7abce": {"t": 1785753954.5708935, "n": 31454, "h": "c4dd70022438b3c6", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtConsequenceView } = require(\"./officialCourtConsequences\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_CONSEQUENCES = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_consequence: \"后果\",\n official_court_response: \"回应\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtConsequenceItems(worldState, items) {\n const courtConsequenceView = buildOfficialCourtConsequenceView(worldState);\n const signals = Array.isArray(courtConsequenceView?.recentSignals)\n ? courtConsequenceView.recentSignals\n : [];\n signals.slice(-MAX_OFFICIAL_COURT_CONSEQUENCES).forEach((signal) => {\n addItem(items, worldState, {\n sourceType: \"official_court_consequence\",\n kind: signal.signalKind || \"official_court_consequence\",\n title: signal.title || signal.statusLabel || \"官场后果信号\",\n summary: signal.publicSummary,\n date: signal,\n turn: signal.generatedAtTurn,\n status: signal.status === \"watchlist\" || signal.status === \"evidence_pending\" ? \"watch\" : \"recorded\",\n riskLabel: signal.statusLabel || signal.signalKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(signal.sourceRefs) ? signal.sourceRefs : []),\n ...(Array.isArray(signal.consequenceRefs) ? signal.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectOfficialCourtConsequenceItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "37a0c4d5eb523d70668c583f3173acb31c3810ea": {"t": 1785753956.476774, "n": 30217, "h": "3dcfebbb5155ca67", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialCourtResponseView } = require(\"./officialCourtResponse\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_OFFICIAL_COURT_RESPONSES = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n official_court_response: \"回应\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectOfficialCourtResponseItems(worldState, items) {\n const courtResponseView = buildOfficialCourtResponseView(worldState);\n const responses = Array.isArray(courtResponseView?.recentResponses)\n ? courtResponseView.recentResponses\n : [];\n responses.slice(-MAX_OFFICIAL_COURT_RESPONSES).forEach((response) => {\n addItem(items, worldState, {\n sourceType: \"official_court_response\",\n kind: response.responseKind || \"official_court_response\",\n title: response.title || response.statusLabel || \"奏议回应\",\n summary: response.publicSummary,\n date: response,\n turn: response.generatedAtTurn,\n status: response.status === \"requested_evidence\" || response.status === \"held_for_deliberation\" ? \"watch\" : \"recorded\",\n riskLabel: response.statusLabel || response.responseKindLabel || \"\",\n relatedLabels: [\n ...(Array.isArray(response.sourceRefs) ? response.sourceRefs : []),\n ...(Array.isArray(response.consequenceRefs) ? response.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialCourtResponseItems(worldState, items);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "2eabfcb2b97993b639dd4ef0809c5be6361776fc": {"t": 1785753958.016304, "n": 28966, "h": "954bb2b96e7dc318", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n official_court_follow_up: \"批复\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n\n const followUps = Array.isArray(officialCareerView?.courtEntryFollowUps)\n ? officialCareerView.courtEntryFollowUps\n : Array.isArray(officialCareerView?.courtEntry?.followUpHistory)\n ? officialCareerView.courtEntry.followUpHistory\n : [];\n followUps.slice(-MAX_OFFICIAL_COURT_ENTRY_FOLLOW_UPS).forEach((followUp) => {\n addItem(items, worldState, {\n sourceType: \"official_court_follow_up\",\n kind: followUp.stage || \"official_court_follow_up\",\n title: followUp.title || followUp.statusLabel || \"奏议后续批复\",\n summary: followUp.publicSummary,\n date: followUp,\n turn: followUp.generatedAtTurn,\n status: followUp.status === \"returned_for_evidence\" ? \"watch\" : \"recorded\",\n riskLabel: followUp.riskDelta > 0 ? \"须补查\" : followUp.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: [\n ...(Array.isArray(followUp.sourceRefs) ? followUp.sourceRefs : []),\n ...(Array.isArray(followUp.consequenceRefs) ? followUp.consequenceRefs : [])\n ]\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "8b67883441cd8dba67662b28407af4b66335b99e": {"t": 1785753959.3819048, "n": 27872, "h": "07430c1c8381f798", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_court_entry: \"奏议\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n\n const resolutions = Array.isArray(officialCareerView?.courtEntryResolutions)\n ? officialCareerView.courtEntryResolutions\n : [];\n resolutions.slice(-MAX_OFFICIAL_COURT_ENTRY_RESOLUTIONS).forEach((resolution) => {\n addItem(items, worldState, {\n sourceType: \"official_court_entry\",\n kind: resolution.submissionKind || \"official_first_month\",\n title: resolution.title || resolution.statusLabel || \"首月奏议裁决\",\n summary: resolution.publicSummary,\n date: resolution,\n turn: resolution.generatedAtTurn,\n status: resolution.status === \"returned_for_evidence\" || resolution.status === \"held_for_inquiry\" ? \"watch\" : \"recorded\",\n riskLabel: resolution.riskDelta > 0 ? \"须补查\" : resolution.meritDelta > 0 ? \"入考成\" : \"\",\n relatedLabels: Array.isArray(resolution.sourceRefs) ? resolution.sourceRefs : []\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "29ea72dc82a0d69cff6de9bd44c2679273bdaf36": {"t": 1785753960.6996589, "n": 26937, "h": "e5f1ce8a7f8fb890", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\nconst { isVisibleActorId } = require(\"./actorMemoryLedger\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_ACTOR_MEMORY_RECORDS = 6;\nconst MAX_SESSION_SUMMARIES = 4;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|relationshipLedger|actorMemoryLedger|sessionSummary|retrievalContext|sealedMapping|sealed_mapping|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal)|\\b(?:statePatch|worldState|provider|proposal|prompt|rawSql)\\b|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\",\n actor_memory: \"记忆\",\n session_summary: \"经历\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectActorMemoryItems(worldState, items) {\n const ledger = worldState.actorMemoryLedger || {};\n const visible = [];\n const memoriesByActor = ledger.memoriesByActor && typeof ledger.memoriesByActor === \"object\"\n ? ledger.memoriesByActor\n : {};\n for (const [actorId, rows] of Object.entries(memoriesByActor)) {\n if (!isVisibleActorId(worldState, actorId)) continue;\n for (const memory of Array.isArray(rows) ? rows : []) {\n if (![\"public\", \"player_visible\", \"relationship_visible\"].includes(memory.visibility)) continue;\n const summary = cleanArchiveText(memory.summary, \"\");\n if (!summary) continue;\n visible.push({\n actorId,\n memory,\n summary,\n salience: clampNumber(memory.salience, 0, 100, 0),\n turn: clampNumber(memory.lastTouchedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n });\n }\n }\n visible\n .sort((first, second) => second.salience - first.salience || second.turn - first.turn)\n .slice(0, MAX_ACTOR_MEMORY_RECORDS)\n .forEach(({ actorId, memory, summary, turn }) => {\n addItem(items, worldState, {\n sourceType: \"actor_memory\",\n kind: memory.type || \"memory\",\n title: memory.typeLabel ? `可见记忆:${memory.typeLabel}` : \"可见记忆\",\n summary,\n date: memory.createdAt || worldState,\n turn,\n status: \"recorded\",\n relatedLabels: [\n actorId,\n ...(Array.isArray(memory.tags) ? memory.tags : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectSessionSummaryItems(worldState, items) {\n const summaries = Array.isArray(worldState.sessionSummary?.monthlySummaries)\n ? worldState.sessionSummary.monthlySummaries\n : [];\n summaries.slice(-MAX_SESSION_SUMMARIES).forEach((summary) => {\n addItem(items, worldState, {\n sourceType: \"session_summary\",\n kind: \"monthly_summary\",\n title: summary.periodLabel || \"月度经历\",\n summary: summary.publicSummary,\n date: summary.generatedAt || worldState,\n turn: summary.generatedAtTurn,\n status: \"recorded\",\n relatedLabels: Array.isArray(summary.highlights) ? summary.highlights.slice(0, 3) : []\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n collectActorMemoryItems(worldState, items);\n collectSessionSummaryItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "16d5f08b5c672c8306aa89e2ec51c04ab8cfce46": {"t": 1785753962.712847, "n": 24180, "h": "1cb6dbd84ac7376b", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_MONTHLY_BRIEFINGS = 4;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n monthly_briefing: \"月报\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\bfile:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/)[^\\s\"'<>]+/gi, \"[redacted-path]\");\n text = text.replace(/[A-Za-z]:[\\\\/][^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectMonthlyBriefingItems(worldState, items) {\n const reports = Array.isArray(worldState.playerMonthlyBriefing?.reports)\n ? worldState.playerMonthlyBriefing.reports\n : [];\n reports.slice(-MAX_MONTHLY_BRIEFINGS).forEach((report) => {\n addItem(items, worldState, {\n sourceType: \"monthly_briefing\",\n kind: report.role || \"official\",\n title: report.title || \"官职月报\",\n summary: report.publicSummary,\n date: report.generatedAt || report,\n turn: report.generatedAtTurn,\n status: \"recorded\",\n riskLabel: Array.isArray(report.riskItems) && report.riskItems.length ? \"月报风险\" : \"\",\n relatedLabels: Array.isArray(report.sourceRefs)\n ? report.sourceRefs.map((ref) => ref.label || ref.id || ref.source)\n : []\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectMonthlyBriefingItems(worldState, items);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "c2c5b1fd3450e42a712405be681ca465b54eb77c": {"t": 1785753964.1385658, "n": 23053, "h": "399314e3369d3fbd", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\",\n appointment_result: \"授官\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectAppointmentTrackItems(worldState, items) {\n const records = Array.isArray(worldState.appointmentTrack?.records)\n ? worldState.appointmentTrack.records\n : [];\n records\n .slice(-MAX_EXAM_RECORDS)\n .forEach((record) => {\n const decision = record.serverDecision || {};\n addItem(items, worldState, {\n sourceType: \"appointment_result\",\n kind: decision.trackKey || record.palaceRank || \"appointment_track\",\n title: decision.officeTitle ? `初授${decision.officeTitle}` : \"殿试授官\",\n summary: record.publicSummary ||\n `${record.examName || \"殿试\"}后,服务器归档授官路径。`,\n date: record.date || worldState,\n turn: record.date?.turnCount ?? currentTurn(worldState),\n status: \"recorded\",\n riskLabel: record.avoidanceChecks?.some?.((check) => check.status === \"blocked\")\n ? \"回避改拟\"\n : \"\",\n relatedLabels: [\n record.palaceRank,\n record.honorTitle,\n decision.trackLabel,\n decision.bureauId,\n ...(Array.isArray(record.avoidanceChecks)\n ? record.avoidanceChecks.map((check) => check.status === \"blocked\" ? \"籍贯回避\" : \"\")\n : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectAppointmentTrackItems(worldState, items);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "50cf24589dd027e0c4a4fdf1beba58b13fd08ab2": {"t": 1785753965.7168367, "n": 21739, "h": "0f0950e05a739fdb", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_EXAM_NETWORK_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\",\n exam_network: \"科场人脉\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction collectExamNetworkItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history\n .filter((entry) => entry.examNetwork)\n .slice(-MAX_EXAM_NETWORK_RECORDS)\n .forEach((entry) => {\n const network = entry.examNetwork || {};\n const sameYearCount = Array.isArray(network.sameYearContacts) ? network.sameYearContacts.length : 0;\n const examinerCount = Array.isArray(network.examinerContacts) ? network.examinerContacts.length : 0;\n addItem(items, worldState, {\n sourceType: \"exam_network\",\n kind: entry.level || network.level || \"exam_network\",\n title: `${entry.examName || network.examName || \"科场\"}人脉`,\n summary: network.publicSummary ||\n `${entry.examName || \"科场\"}归档同年${sameYearCount}人、座师考官${examinerCount}人。`,\n date: network.date || examDateSource(entry) || worldState,\n turn: network.date?.turnCount ??\n entry.sceneTime?.updatedAt?.turnCount ??\n entry.sceneTime?.startedAt?.turnCount ??\n currentTurn(worldState),\n status: \"recorded\",\n relatedLabels: [\n `同年${sameYearCount}人`,\n `座师考官${examinerCount}人`,\n ...(Array.isArray(network.examinerContacts) ? network.examinerContacts.map((contact) => contact.role) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n collectExamNetworkItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "65344622fa805bc8c63147d811d34cf38a1eb7f1": {"t": 1785753967.7895396, "n": 20245, "h": "dbe7a54c64baaf83", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n const honor = entry.examHonor?.currentHonor || (Array.isArray(entry.examHonor?.awards) ? entry.examHonor.awards.at(-1) : null);\n const achievement = entry.examHonor?.currentAchievement || null;\n const honorText = achievement?.title || honor?.title || \"\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}${honorText ? `,${cleanArchiveText(honorText, \"\", 60)}` : \"\"}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "0a31acc7f4f3cefaf561cc37c9cae8c8ed19dd99": {"t": 1785753970.2609959, "n": 19919, "h": "a41a7e4ff94763c7", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildIntelligenceRumorView } = require(\"./intelligenceRumors\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_INTELLIGENCE_RUMORS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectIntelligenceRumorItems(worldState, items, intelligenceRumorView) {\n const rumors = Array.isArray(intelligenceRumorView?.publicRumors)\n ? intelligenceRumorView.publicRumors\n : [];\n rumors.slice(0, MAX_INTELLIGENCE_RUMORS).forEach((rumor) => {\n addItem(items, worldState, {\n sourceType: \"intelligence_rumor\",\n kind: rumor.kind,\n title: rumor.title || rumor.kindLabel,\n summary: rumor.publicSummary,\n date: rumor.date,\n turn: rumor.lastUpdatedTurn,\n status: rumor.credibilityTier === \"unverified\" ? \"watch\" : \"recorded\",\n riskLabel: rumor.credibilityLabel,\n relatedLabels: [\n rumor.channel,\n rumor.kindLabel,\n ...(Array.isArray(rumor.relatedRefs) ? rumor.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const intelligenceRumorView = buildIntelligenceRumorView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectIntelligenceRumorItems(worldState, items, intelligenceRumorView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "9ae7da1f84d84d4ef388d30b034b7a4d77f1a04a": {"t": 1785753972.0515375, "n": 18799, "h": "8cf3fbbd01cc5df6", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst {\n buildHistoricalEventArchiveView\n} = require(\"./historicalEventArchive\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_HISTORICAL_EVENT_CHAINS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectHistoricalEventItems(worldState, items, historicalEventArchiveView) {\n const chains = Array.isArray(historicalEventArchiveView?.publicChains)\n ? historicalEventArchiveView.publicChains\n : [];\n chains.slice(0, MAX_HISTORICAL_EVENT_CHAINS).forEach((chain) => {\n addItem(items, worldState, {\n sourceType: \"historical_event_chain\",\n kind: chain.domain,\n title: chain.title,\n summary: chain.publicSummary,\n date: chain.date,\n turn: chain.lastUpdatedTurn,\n status: chain.status === \"recorded\" ? \"recorded\" : \"watch\",\n riskLabel: chain.statusLabel,\n relatedLabels: [\n chain.domainLabel,\n ...(Array.isArray(chain.relatedRefs) ? chain.relatedRefs.map((ref) => ref.label || ref.id) : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const historicalEventArchiveView = buildHistoricalEventArchiveView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectHistoricalEventItems(worldState, items, historicalEventArchiveView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "baf79def78e57780ee438d4213becea1fcbe7d6a": {"t": 1785753973.5616739, "n": 17679, "h": "0ae872e6409708d3", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildEconomicFiscalView } = require(\"./economicFiscal\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_ECONOMIC_INCIDENTS = 5;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction collectEconomicFiscalItems(worldState, items, economicFiscalView) {\n const incidents = Array.isArray(economicFiscalView?.marketIncidents)\n ? economicFiscalView.marketIncidents\n : [];\n incidents.slice(0, MAX_ECONOMIC_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"economic_fiscal\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.countryId,\n incident.cityId,\n incident.routeId,\n incident.bureauId\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const economicFiscalView = buildEconomicFiscalView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectEconomicFiscalItems(worldState, items, economicFiscalView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "fc9dd2e83f0ed8ff45690b6eda3aed678b72167e": {"t": 1785753974.946762, "n": 16348, "h": "52d1d1bfab6a9c3e", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildMilitaryDiplomacyView } = require(\"./militaryDiplomacy\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_MILITARY_INCIDENTS = 5;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView) {\n const incidents = Array.isArray(militaryDiplomacyView?.frontierIncidents)\n ? militaryDiplomacyView.frontierIncidents\n : [];\n incidents.slice(0, MAX_MILITARY_INCIDENTS).forEach((incident) => {\n addItem(items, worldState, {\n sourceType: \"military_diplomacy\",\n kind: incident.kind,\n title: incident.title,\n summary: incident.publicSummary,\n date: incident.date,\n turn: incident.lastUpdatedTurn,\n status: incident.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: incident.statusLabel,\n relatedLabels: [\n incident.kindLabel,\n incident.frontierZoneId,\n incident.neighborCountryId,\n ...(Array.isArray(incident.relatedCityIds) ? incident.relatedCityIds : [])\n ].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const militaryDiplomacyView = buildMilitaryDiplomacyView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectMilitaryDiplomacyItems(worldState, items, militaryDiplomacyView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "d4c651f4ba4b8a5db102f8e6600abdaf961c393d": {"t": 1785753976.281575, "n": 15200, "h": "16e3cd7a1d9cd925", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildLocalAffairsDocketView } = require(\"./localAffairsDockets\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_LOCAL_DOCKETS = 6;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction collectLocalDocketItems(worldState, items, localAffairsDocketView) {\n const dockets = Array.isArray(localAffairsDocketView?.dockets)\n ? localAffairsDocketView.dockets\n : [];\n dockets.slice(0, MAX_LOCAL_DOCKETS).forEach((docket) => {\n const relatedLabels = [\n docket.domainLabel,\n docket.cityName,\n docket.bureauId,\n ...(Array.isArray(docket.assessmentHint?.tags) ? docket.assessmentHint.tags : [])\n ].filter(Boolean);\n addItem(items, worldState, {\n sourceType: \"local_docket\",\n kind: docket.domain,\n title: docket.title,\n summary: docket.publicDocket || docket.publicSummary,\n date: docket.date,\n turn: docket.lastUpdatedTurn,\n status: docket.status === \"routine\" ? \"recorded\" : \"watch\",\n riskLabel: docket.statusLabel,\n relatedLabels\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const localAffairsDocketView = buildLocalAffairsDocketView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectLocalDocketItems(worldState, items, localAffairsDocketView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "1924ba610d61deec782a86808a243f1b6840d5eb": {"t": 1785753977.6774952, "n": 14088, "h": "20e93507b7d9ee63", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments\n .filter((record) => !isAppointmentPoolAssessment(record))\n .slice(-MAX_OFFICIAL_ASSESSMENTS)\n .forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction isAppointmentPoolAssessment(record = {}) {\n const id = typeof record.id === \"string\" ? record.id : \"\";\n const postingId = typeof record.postingId === \"string\" ? record.postingId : \"\";\n // S63 任命池考成是可见案牍压力 projection,不是已经发生的公开历史事件。\n return id.startsWith(\"assessment-s63-\") || postingId.startsWith(\"posting-s63-\");\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "112a35c512cd84fd50abccd502756c1f14b04947": {"t": 1785753979.0914786, "n": 13663, "h": "04f147890100ddbb", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_OFFICIAL_ASSESSMENTS = 4;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction collectOfficialAssessmentItems(worldState, items, officialPostingsView) {\n const assessments = Array.isArray(officialPostingsView?.assessmentRecords)\n ? officialPostingsView.assessmentRecords\n : [];\n assessments.slice(-MAX_OFFICIAL_ASSESSMENTS).forEach((record) => {\n addItem(items, worldState, {\n sourceType: \"official_assessment\",\n kind: \"assessment\",\n title: \"任所考成\",\n summary: record.publicFinding || record.publicSummary,\n date: record.date,\n turn: record.date?.turn ?? record.lastUpdatedTurn,\n status: record.status === \"resolved\" || record.status === \"archived\" ? \"resolved\" : \"watch\",\n riskLabel: record.riskScore >= 60 ? \"考成风险\" : record.meritScore >= 70 ? \"考成向好\" : \"\",\n relatedLabels: [record.officeId, record.bureauId].filter(Boolean)\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const officialPostingsView = buildOfficialPostingsView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectOfficialAssessmentItems(worldState, items, officialPostingsView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "acfe9c1f4121f03544ea0705146f3a1e70f74e62": {"t": 1785753980.2836502, "n": 12558, "h": "899a27ace1f9cb4a", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\nconst MIN_PAGE_SIZE = 1;\nconst MAX_PAGE_SIZE = 50;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(value, MIN_PAGE_SIZE, MAX_PAGE_SIZE, MAX_ARCHIVE_ITEMS);\n}\n\nfunction buildEventArchiveIndexItems(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectExamItems(worldState, items);\n\n return items.sort(sortArchiveItems);\n}\n\nfunction buildPagination(totalItems, options = {}) {\n const pageSize = normalizePageSize(options.pageSize ?? options.limit ?? MAX_ARCHIVE_ITEMS);\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const page = Math.min(normalizePage(options.page), totalPages);\n const offset = (page - 1) * pageSize;\n return {\n page,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: page > 1,\n hasNextPage: page < totalPages,\n offset\n };\n}\n\nfunction buildEventArchiveView(worldState = {}, options = {}) {\n const sortedItems = buildEventArchiveIndexItems(worldState);\n const pagination = buildPagination(sortedItems.length, options);\n const pageItems = sortedItems.slice(pagination.offset, pagination.offset + pagination.pageSize);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: pageItems,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n pageCounts: {\n total: pageItems.length,\n ...countSources(pageItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveIndexItems,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "80a0c074662d5ac8dfcfc9fce25f0b47608e01c9": {"t": 1785753981.5234187, "n": 11176, "h": "4be9cf29352f2f6f", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction buildEventArchiveView(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectExamItems(worldState, items);\n\n const sortedItems = items.sort(sortArchiveItems).slice(0, MAX_ARCHIVE_ITEMS);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: sortedItems,\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}, "bac7d2faade7e94279095e1f86f498696f6ef1ed": {"t": 1785753982.73718, "n": 11142, "h": "d25cb2463dd4302c", "c": "const { buildLongTermEventView } = require(\"./longTermEvents\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { formatYearMonthPeriod, normalizeMonth, normalizeTenDayPeriod, normalizeYear } = require(\"./time\");\nconst { buildWorldThreadView } = require(\"./worldThreads\");\n\nconst EVENT_ARCHIVE_SCHEMA_VERSION = 1;\nconst MAX_ARCHIVE_ITEMS = 24;\nconst MAX_HISTORY_ITEMS = 8;\nconst MAX_THREADS = 6;\nconst MAX_LONG_TERM_EVENTS = 5;\nconst MAX_OFFICIAL_OUTCOMES = 5;\nconst MAX_EXAM_RECORDS = 5;\nconst MAX_TEXT_LENGTH = 180;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_ARCHIVE_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{8,})/i;\n\nconst SOURCE_LABELS = {\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n exam_record: \"科场\"\n};\n\nconst STATUS_LABELS = {\n recorded: \"已记\",\n active: \"观察\",\n watch: \"留察\",\n resolved: \"归档\",\n submitted: \"交卷\"\n};\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction redactArchiveText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanArchiveText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(raw)) return fallback;\n const redacted = redactArchiveText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_ARCHIVE_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeDateSource(worldState = {}, source = {}) {\n const candidate = isPlainObject(source) ? source : {};\n const year = normalizeYear(candidate.year ?? candidate.currentYear ?? worldState.year);\n const month = normalizeMonth(candidate.month ?? candidate.currentMonth ?? worldState.month);\n const tenDayPeriod = normalizeTenDayPeriod(\n candidate.tenDayPeriod ?? candidate.currentTenDayPeriod ?? worldState.tenDayPeriod\n );\n const dynasty = candidate.dynasty || worldState.dynasty || \"\";\n return {\n dynasty,\n year,\n month,\n tenDayPeriod,\n dateLabel: candidate.label || formatYearMonthPeriod({ dynasty, year, month, tenDayPeriod })\n };\n}\n\nfunction makeArchiveItem(worldState, index, fields = {}) {\n const date = normalizeDateSource(worldState, fields.date || fields);\n const sourceType = SOURCE_LABELS[fields.sourceType] ? fields.sourceType : \"event_history\";\n const status = STATUS_LABELS[fields.status] ? fields.status : \"recorded\";\n const title = cleanArchiveText(fields.title, SOURCE_LABELS[sourceType] || \"事件\");\n const summary = cleanArchiveText(fields.summary, \"\");\n if (!summary) return null;\n const turn = clampNumber(fields.turn ?? fields.resolvedTurn ?? fields.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState));\n const relatedLabels = Array.isArray(fields.relatedLabels)\n ? fields.relatedLabels.map((label) => cleanArchiveText(label, \"\", 60)).filter(Boolean).slice(0, 6)\n : [];\n\n return {\n id: `EA-${String(index).padStart(3, \"0\")}-${sourceType}`,\n sourceType,\n sourceLabel: SOURCE_LABELS[sourceType],\n kind: cleanArchiveText(fields.kind || sourceType, sourceType, 60),\n title,\n summary,\n year: date.year,\n month: date.month,\n tenDayPeriod: date.tenDayPeriod,\n dateLabel: date.dateLabel,\n turn,\n visibility: \"public\",\n status,\n statusLabel: STATUS_LABELS[status],\n riskLabel: cleanArchiveText(fields.riskLabel, \"\", 40),\n relatedLabels\n };\n}\n\nfunction addItem(items, worldState, fields) {\n const item = makeArchiveItem(worldState, items.length + 1, fields);\n if (item) items.push(item);\n}\n\nfunction collectHistoryItems(worldState, items) {\n const history = Array.isArray(worldState.eventHistory) ? worldState.eventHistory : [];\n const visibleHistory = history.slice(-MAX_HISTORY_ITEMS);\n const firstTurn = Math.max(0, currentTurn(worldState) - visibleHistory.length + 1);\n visibleHistory.forEach((entry, index) => {\n addItem(items, worldState, {\n sourceType: \"event_history\",\n kind: \"event\",\n title: \"近事入档\",\n summary: entry,\n turn: firstTurn + index,\n status: \"recorded\"\n });\n });\n}\n\nfunction collectWorldThreadItems(worldState, items, worldThreadView) {\n const activeThreads = Array.isArray(worldThreadView?.activeThreads) ? worldThreadView.activeThreads : [];\n activeThreads.slice(0, MAX_THREADS).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.summary || thread.goal,\n turn: thread.lastUpdatedTurn ?? thread.createdTurn,\n status: thread.status === \"watch\" ? \"watch\" : \"active\",\n riskLabel: thread.riskLabel,\n relatedLabels: thread.relatedLabels?.summary || []\n });\n });\n\n const resolvedThreads = Array.isArray(worldThreadView?.recentResolved) ? worldThreadView.recentResolved : [];\n resolvedThreads.slice(-3).forEach((thread) => {\n addItem(items, worldState, {\n sourceType: \"world_thread\",\n kind: thread.kind,\n title: thread.title,\n summary: thread.outcome,\n turn: thread.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectLongTermItems(worldState, items, longTermEventView) {\n const activeEvents = Array.isArray(longTermEventView?.activeEvents) ? longTermEventView.activeEvents : [];\n activeEvents.slice(0, MAX_LONG_TERM_EVENTS).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.summary,\n date: {\n year: event.startedYear,\n month: event.startedMonth,\n tenDayPeriod: 1\n },\n status: \"active\",\n riskLabel: event.severity >= 3 ? \"急重\" : event.severity >= 2 ? \"有牵连\" : \"可观察\"\n });\n });\n\n const resolved = Array.isArray(longTermEventView?.recentResolved) ? longTermEventView.recentResolved : [];\n resolved.slice(-3).forEach((event) => {\n addItem(items, worldState, {\n sourceType: \"long_term_event\",\n kind: event.type,\n title: event.title,\n summary: event.outcome,\n turn: event.resolvedTurn,\n status: \"resolved\"\n });\n });\n}\n\nfunction collectOfficialItems(worldState, items, officialCareerView) {\n const outcomes = Array.isArray(officialCareerView?.recentOutcomes) ? officialCareerView.recentOutcomes : [];\n outcomes.slice(-MAX_OFFICIAL_OUTCOMES).forEach((outcome) => {\n const from = cleanArchiveText(outcome.officeTitleBefore, \"\", 80);\n const to = cleanArchiveText(outcome.officeTitleAfter, \"\", 80);\n addItem(items, worldState, {\n sourceType: \"official_career\",\n kind: outcome.type,\n title: outcome.label,\n summary: [outcome.reason, from || to ? `职名:${from || \"未授\"} -> ${to || \"未授\"}` : \"\"]\n .filter(Boolean)\n .join(\";\"),\n date: outcome,\n turn: outcome.turn,\n status: outcome.status === \"pending\" ? \"watch\" : \"resolved\"\n });\n });\n}\n\nfunction examDateSource(entry = {}) {\n return entry.sceneTime?.updatedAt ||\n entry.examSubmittedAt ||\n entry.sceneTime?.startedAt ||\n entry.examStartedAt ||\n entry.examCalendar ||\n entry.entryPreparation?.examCalendar ||\n null;\n}\n\nfunction collectExamItems(worldState, items) {\n const history = Array.isArray(worldState.player?.examHistory) ? worldState.player.examHistory : [];\n history.slice(-MAX_EXAM_RECORDS).forEach((entry) => {\n const score = entry.score?.overall_score ?? null;\n const rank = cleanArchiveText(entry.score?.rank || entry.promotionResult?.rank, \"\", 60);\n const passed = entry.promotionResult?.passed ? \"取中\" : \"未中\";\n addItem(items, worldState, {\n sourceType: \"exam_record\",\n kind: entry.level || \"exam\",\n title: entry.examName || \"科场\",\n summary: `${entry.examName || \"科场\"}交卷,得${score ?? \"-\"}分,${rank || passed}。`,\n date: examDateSource(entry) || worldState,\n turn: entry.sceneTime?.updatedAt?.turnCount ?? entry.sceneTime?.startedAt?.turnCount ?? currentTurn(worldState),\n status: \"submitted\",\n riskLabel: entry.authenticityCheck?.severeCheat ? \"监试风险\" : \"\"\n });\n });\n}\n\nfunction sortArchiveItems(first, second) {\n if (second.turn !== first.turn) return second.turn - first.turn;\n if (second.year !== first.year) return second.year - first.year;\n if (second.month !== first.month) return second.month - first.month;\n if (second.tenDayPeriod !== first.tenDayPeriod) return second.tenDayPeriod - first.tenDayPeriod;\n return first.id.localeCompare(second.id);\n}\n\nfunction countSources(items) {\n return items.reduce((counts, item) => {\n counts[item.sourceType] = (counts[item.sourceType] || 0) + 1;\n return counts;\n }, {});\n}\n\nfunction buildEventArchiveView(worldState = {}) {\n const worldThreadView = buildWorldThreadView(worldState);\n const longTermEventView = buildLongTermEventView(worldState);\n const officialCareerView = buildOfficialCareerView(worldState);\n const items = [];\n\n collectHistoryItems(worldState, items);\n collectWorldThreadItems(worldState, items, worldThreadView);\n collectLongTermItems(worldState, items, longTermEventView);\n collectOfficialItems(worldState, items, officialCareerView);\n collectExamItems(worldState, items);\n\n const sortedItems = items.sort(sortArchiveItems).slice(0, MAX_ARCHIVE_ITEMS);\n return {\n schemaVersion: EVENT_ARCHIVE_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n dateLabel: formatYearMonthPeriod(worldState),\n items: sortedItems,\n counts: {\n total: sortedItems.length,\n ...countSources(sortedItems)\n },\n hiddenNotice: \"事件档案只收录服务器整理后的公开卷宗;本地诊断、提示词、密钥路径和模型提案均已过滤。\"\n };\n}\n\nmodule.exports = {\n EVENT_ARCHIVE_SCHEMA_VERSION,\n buildEventArchiveView,\n cleanArchiveText\n};\n"}}, "src/game/quickActionSuggestions.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745547.9657097, "n": 17829, "h": "66d98a7ac6bd3ce5", "c": "const { buildPlayerAiActorProfile } = require(\"./aiActorProfiles\");\nconst {\n assertResolverInputSafe,\n buildResolverInputContext\n} = require(\"./resolverInputContext\");\nconst { RESOLVER_INPUT_DOMAINS } = require(\"./resolverInputConfig\");\nconst { formatYearMonthPeriod } = require(\"./time\");\n\nconst QUICK_ACTION_SCHEMA_VERSION = \"s75.9-quick-actions.v1\";\nconst QUICK_ACTION_MAX_COUNT = 3;\nconst QUICK_ACTION_MAX_DRAFT_PREVIEW = 80;\nconst QUICK_ACTION_MAX_TEXT = 96;\nconst QUICK_ACTION_MAX_TITLE = 12;\nconst QUICK_ACTION_MAX_TAGS = 6;\nconst QUICK_ACTION_MAX_EVIDENCE_REFS = 4;\n\nconst QUICK_ACTION_SOURCES = Object.freeze([\"local-rule\", \"mock-ai\", \"provider-ai\"]);\nconst QUICK_ACTION_TOOL_INTENTS = Object.freeze([\n \"study\",\n \"exam\",\n \"patrol\",\n \"case\",\n \"memorial\",\n \"march\",\n \"court\",\n \"travel\",\n \"office\",\n \"generic\"\n]);\n\nconst SENSITIVE_QUICK_ACTION_PATTERN =\n /(SEALED_[A-Z0-9_]+|hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal|state|row)|\\b(?:statePatch|worldState|provider\\s+payload|provider\\s+proposal|rawSql|SQL|sqlite|server\\.)\\b|完整\\s*prompt|完整提示词|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|safe_search_(?:index|fts)|(?:geo|people)_[A-Za-z0-9_]+|office_(?!read\\b)[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,})/i;\nconst RESULT_CLAIM_QUICK_ACTION_PATTERN =\n /(?:已|已经)(?:审结|结案|裁决|处置|平定|破获|赈济|授官|任命|罢免|定罪|升迁|提拔|推进完成|执行完毕)|(?:立即|直接)(?:任命|罢免|处死|定罪|结案|授官|平定|裁决)/i;\n\nconst ROLE_ALIASES = Object.freeze({\n emperor: \"emperor\",\n 皇帝: \"emperor\",\n scholar: \"scholar\",\n 书生: \"scholar\",\n general: \"general\",\n 将领: \"general\",\n magistrate: \"magistrate\",\n 地方官: \"magistrate\",\n minister: \"minister\",\n 大臣: \"minister\",\n official: \"official\",\n 官员: \"official\",\n 入仕官员: \"official\"\n});\n\nconst ROLE_ALLOWED_TOOL_INTENTS = Object.freeze({\n emperor: Object.freeze([\"court\", \"memorial\", \"office\", \"patrol\", \"generic\"]),\n scholar: Object.freeze([\"study\", \"exam\", \"travel\", \"generic\"]),\n general: Object.freeze([\"march\", \"patrol\", \"memorial\", \"travel\", \"generic\"]),\n magistrate: Object.freeze([\"case\", \"patrol\", \"memorial\", \"office\", \"generic\"]),\n minister: Object.freeze([\"memorial\", \"court\", \"office\", \"patrol\", \"generic\"]),\n official: Object.freeze([\"memorial\", \"office\", \"court\", \"patrol\", \"generic\"])\n});\n\nconst TOOL_GROUP_TO_INTENT = Object.freeze({\n study: \"study\",\n exam: \"exam\",\n judicial: \"case\",\n city_policy: \"patrol\",\n military: \"march\",\n diplomacy: \"travel\",\n ruler: \"court\",\n career: \"office\",\n report: \"memorial\",\n map: \"travel\",\n time: \"generic\",\n personal_action: \"generic\"\n});\n\nconst LOCAL_ROLE_SUGGESTIONS = Object.freeze({\n emperor: Object.freeze([\n Object.freeze({ id: \"emperor-edict\", title: \"宣旨\", label: \"宣旨\", text: \"宣旨整饬吏治,命中枢三日内具奏各省积弊。\", roleTags: Object.freeze([\"emperor\", \"edict\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"emperor-vermilion\", title: \"朱批\", label: \"朱批\", text: \"朱批近日奏折,询问钱粮、边防与民生三事。\", roleTags: Object.freeze([\"emperor\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"emperor-court\", title: \"召见群臣\", label: \"召见群臣\", text: \"召见群臣廷议,听取各部对当前局势的陈奏。\", roleTags: Object.freeze([\"emperor\", \"court\"]), toolIntent: \"court\" })\n ]),\n scholar: Object.freeze([\n Object.freeze({ id: \"scholar-study\", title: \"研读\", label: \"研读\", text: \"闭门研读经义,整理近日所得,准备下一场考试。\", roleTags: Object.freeze([\"scholar\", \"study\"]), toolIntent: \"study\" }),\n Object.freeze({ id: \"scholar-essay\", title: \"作文\", label: \"作文\", text: \"拟作一篇策论,请师友指出破题与立意得失。\", roleTags: Object.freeze([\"scholar\", \"essay\"]), toolIntent: \"study\" }),\n Object.freeze({ id: \"scholar-exam\", title: \"赴考\", label: \"赴考\", text: \"打听考期与贡院规矩,整备行装准备赴考。\", roleTags: Object.freeze([\"scholar\", \"exam\"]), toolIntent: \"exam\" })\n ]),\n general: Object.freeze([\n Object.freeze({ id: \"general-deploy\", title: \"遣将\", label: \"遣将\", text: \"遣将巡查前哨,回报粮道、军心与敌情虚实。\", roleTags: Object.freeze([\"general\", \"scout\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"general-camp\", title: \"巡营\", label: \"巡营\", text: \"亲自巡营,查点甲仗粮草,并安抚将士。\", roleTags: Object.freeze([\"general\", \"camp\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"general-report\", title: \"上战报\", label: \"上战报\", text: \"整理边情与军务得失,上战报请朝廷裁示。\", roleTags: Object.freeze([\"general\", \"report\"]), toolIntent: \"memorial\" })\n ]),\n magistrate: Object.freeze([\n Object.freeze({ id: \"magistrate-trial\", title: \"审案\", label: \"审案\", text: \"升堂审理积案,先核公开证词与案牍记录。\", roleTags: Object.freeze([\"magistrate\", \"case\"]), toolIntent: \"case\" }),\n Object.freeze({ id: \"magistrate-relief\", title: \"赈济\", label: \"赈济\", text: \"查问灾情与仓储,拟定赈济安民之策。\", roleTags: Object.freeze([\"magistrate\", \"relief\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"magistrate-dike\", title: \"修堤\", label: \"修堤\", text: \"召集里正与工匠,勘查河堤险段并筹措修缮。\", roleTags: Object.freeze([\"magistrate\", \"dike\"]), toolIntent: \"office\" })\n ]),\n minister: Object.freeze([\n Object.freeze({ id: \"minister-memorial\", title: \"上疏\", label: \"上疏\", text: \"上疏陈明时弊,提出可由朝廷裁量的三条办法。\", roleTags: Object.freeze([\"minister\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"minister-council\", title: \"会商\", label: \"会商\", text: \"约同僚会商部务,分辨轻重缓急后再行具奏。\", roleTags: Object.freeze([\"minister\", \"council\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"minister-docket\", title: \"查阅案牍\", label: \"查阅案牍\", text: \"查阅近日案牍与公文,摘出关涉民生与官声的要点。\", roleTags: Object.freeze([\"minister\", \"docket\"]), toolIntent: \"office\" })\n ]),\n official: Object.freeze([\n Object.freeze({ id: \"official-memorial\", title: \"上疏\", label: \"上疏\", text: \"上疏陈明任内见闻,请求朝廷明示处置章程。\", roleTags: Object.freeze([\"official\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"official-council\", title: \"会商\", label: \"会商\", text: \"拜会同僚会商公事,先求稳妥可行之策。\", roleTags: Object.freeze([\"official\", \"council\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"official-docket\", title: \"查阅案牍\", label: \"查阅案牍\", text: \"查阅案牍与考成记录,整理下一步施政重点。\", roleTags: Object.freeze([\"official\", \"docket\"]), toolIntent: \"office\" })\n ])\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampInteger(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = 120) {\n if (typeof value !== \"string\") return fallback;\n const text = value.replace(/\\s+/g, \" \").trim();\n if (!text || SENSITIVE_QUICK_ACTION_PATTERN.test(text) || RESULT_CLAIM_QUICK_ACTION_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? text.slice(0, maxLength) : text;\n}\n\nfunction cleanTag(value) {\n const text = cleanText(String(value || \"\"), \"\", 32)\n .replace(/[^A-Za-z0-9_\\-\\u4e00-\\u9fa5]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return text || \"\";\n}\n\nfunction normalizeRole(role) {\n return ROLE_ALIASES[String(role || \"\").trim()] || \"official\";\n}\n\nfunction normalizeQuickActionRequest(body = {}) {\n const input = isPlainObject(body) ? body : {};\n return {\n page: cleanText(input.page, \"game\", 32) || \"game\",\n draftPreview: cleanText(input.draftPreview || input.currentDraftPreview, \"\", QUICK_ACTION_MAX_DRAFT_PREVIEW),\n count: clampInteger(input.count ?? input.limit, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT)\n };\n}\n\nfunction buildPlayerSummary(worldState = {}) {\n const player = worldState.player || {};\n return {\n name: cleanText(player.name, \"未题名\", 48),\n role: normalizeRole(player.role),\n roleLabel: cleanText(player.roleLabel || player.role, \"\", 48),\n position: cleanText(player.position || player.officeTitle, \"\", 80),\n examRank: cleanText(player.examRank || player.palaceRank, \"\", 40),\n officeTitle: cleanText(player.officeTitle, \"\", 80),\n countyName: cleanText(player.countyName, \"\", 80)\n };\n}\n\nfunction flattenEvidence(context = {}, limit = 12) {\n const evidence = [];\n for (const domain of RESOLVER_INPUT_DOMAINS) {\n for (const item of Array.isArray(context[domain]) ? context[domain] : []) {\n evidence.push({\n refId: item.refId,\n sourceView: item.sourceView,\n domain: item.domain,\n label: item.label,\n summary: item.summary,\n visibility: item.visibility,\n confidence: item.confidence,\n freshness: item.freshness\n });\n if (evidence.length >= limit) return evidence;\n }\n }\n return evidence;\n}\n\nfunction buildRouteViewFlags(context = {}, worldState = {}) {\n return {\n hasPublicEvidence: RESOLVER_INPUT_DOMAINS.some((domain) => Array.isArray(context[domain]) && context[domain].length > 0),\n hasMapContext: Array.isArray(context.map) && context.map.length > 0,\n hasEventArchive: Array.isArray(context.events) && context.events.length > 0,\n hasPeopleView: Array.isArray(context.people) && context.people.length > 0,\n hasOfficeView: Array.isArray(context.offices) && context.offices.length > 0,\n hasExamContext: Boolean(worldState.activeExam || worldState.player?.examRank || worldState.studyProfile)\n };\n}\n\nfunction buildToolCapabilities(actorProfile = {}) {\n const allowedGroups = Array.isArray(actorProfile.allowedToolGroups) ? actorProfile.allowedToolGroups : [];\n return allowedGroups\n .map((group) => {\n const intent = TOOL_GROUP_TO_INTENT[group] || \"generic\";\n return {\n group,\n toolIntent: intent,\n boundary: \"只可生成行动草稿;工具执行、proposal 裁决和状态写入均由服务器处理。\"\n };\n })\n .filter((item, index, source) => source.findIndex((other) => other.group === item.group) === index)\n .slice(0, 16);\n}\n\nfunction buildQuickActionContext(worldState = {}, request = {}) {\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const resolverContext = buildResolverInputContext(worldState, {\n includeSessionId: false,\n eventArchivePageSize: 12,\n sceneType: \"quick_action\",\n intentType: \"quick_action\",\n requestSummary: request.draftPreview || \"快捷行动建议\",\n actorProfile,\n domainCaps: {\n geography: 3,\n people: 3,\n offices: 3,\n economy: 2,\n military: 2,\n events: 4,\n intel: 2,\n player: 3,\n map: 3,\n memory: 2\n }\n });\n assertResolverInputSafe(resolverContext);\n const evidenceRefs = flattenEvidence(resolverContext, 12);\n const player = buildPlayerSummary(worldState);\n const context = {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n page: request.page || \"game\",\n requestedCount: request.count || QUICK_ACTION_MAX_COUNT,\n draftPreview: request.draftPreview || \"\",\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n date: {\n label: formatYearMonthPeriod(worldState),\n year: worldState.year,\n month: worldState.month,\n tenDayPeriod: worldState.tenDayPeriod\n },\n player,\n routeViewFlags: buildRouteViewFlags(resolverContext, worldState),\n toolCapabilities: buildToolCapabilities(actorProfile),\n evidenceRefs\n };\n assertQuickActionSafe(context, { allowProviderSource: true });\n return context;\n}\n\nfunction withSource(suggestion, source) {\n return {\n ...suggestion,\n source,\n sourceLabel: source,\n status: \"ready\",\n evidenceRefs: []\n };\n}\n\nfunction buildLocalQuickActionSuggestions(contextOrWorldState = {}, options = {}) {\n const source = QUICK_ACTION_SOURCES.includes(options.source) ? options.source : \"local-rule\";\n const player = contextOrWorldState.player?.role\n ? contextOrWorldState.player\n : buildPlayerSummary(contextOrWorldState);\n const role = normalizeRole(player.role);\n const limit = clampInteger(options.count ?? contextOrWorldState.requestedCount, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT);\n const base = LOCAL_ROLE_SUGGESTIONS[role] || LOCAL_ROLE_SUGGESTIONS.official;\n return base.slice(0, limit).map((suggestion) => withSource(suggestion, source));\n}\n\nfunction buildLocalQuickActionResponse(worldState = {}, request = {}, options = {}) {\n const context = options.context || buildQuickActionContext(worldState, request);\n const source = QUICK_ACTION_SOURCES.includes(options.source) ? options.source : \"local-rule\";\n const suggestions = buildLocalQuickActionSuggestions(context, {\n source,\n count: request.count || context.requestedCount\n });\n return {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n sessionId: cleanText(worldState.sessionId, \"\", 80),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n source,\n status: options.status || (source === \"local-rule\" ? \"fallback\" : \"ready\"),\n stale: false,\n fallbackReason: cleanText(options.fallbackReason, \"\", 80) || undefined,\n quickActionSuggestions: suggestions.map((suggestion, index) => normalizeSuggestion(\n suggestion,\n context,\n index,\n { fallbackSource: source, role: context.player?.role }\n )).filter(Boolean)\n };\n}\n\nfunction normalizeSource(value, fallbackSource = \"provider-ai\") {\n const source = String(value || \"\").trim();\n if (QUICK_ACTION_SOURCES.includes(source)) return source;\n return fallbackSource;\n}\n\nfunction normalizeToolIntent(value, role) {\n const intent = QUICK_ACTION_TOOL_INTENTS.includes(value) ? value : \"generic\";\n const allowed = ROLE_ALLOWED_TOOL_INTENTS[normalizeRole(role)] || ROLE_ALLOWED_TOOL_INTENTS.official;\n return allowed.includes(intent) ? intent : \"\";\n}\n\nfunction normalizeEvidenceRefs(refs, allowedRefIds) {\n if (!Array.isArray(refs)) return [];\n const accepted = [];\n for (const ref of refs) {\n const text = cleanText(String(ref || \"\"), \"\", 120);\n if (!text || !allowedRefIds.has(text) || accepted.includes(text)) continue;\n accepted.push(text);\n if (accepted.length >= QUICK_ACTION_MAX_EVIDENCE_REFS) break;\n }\n return accepted;\n}\n\nfunction normalizeSuggestion(suggestion, context = {}, index = 0, options = {}) {\n if (!isPlainObject(suggestion)) return null;\n const role = normalizeRole(options.role || context.player?.role);\n const source = normalizeSource(suggestion.source, options.fallbackSource || \"provider-ai\");\n if (options.expectedSource && source !== options.expectedSource) return null;\n const toolIntent = normalizeToolIntent(suggestion.toolIntent, role);\n if (!toolIntent) return null;\n\n const allowedRefIds = new Set((context.evidenceRefs || []).map((ref) => ref.refId).filter(Boolean));\n const title = cleanText(suggestion.title, \"\", QUICK_ACTION_MAX_TITLE);\n const label = cleanText(suggestion.label || suggestion.title, title, QUICK_ACTION_MAX_TITLE);\n const text = cleanText(suggestion.text, \"\", QUICK_ACTION_MAX_TEXT);\n if (!title || !label || !text) return null;\n\n const evidenceRefs = normalizeEvidenceRefs(suggestion.evidenceRefs, allowedRefIds);\n if (Array.isArray(suggestion.evidenceRefs) && suggestion.evidenceRefs.length > 0 && evidenceRefs.length === 0) {\n return null;\n }\n\n const roleTags = Array.isArray(suggestion.roleTags)\n ? suggestion.roleTags.map(cleanTag).filter(Boolean)\n : [];\n if (!roleTags.includes(role)) roleTags.unshift(role);\n\n return {\n id: cleanTag(suggestion.id) || `quick-${source}-${role}-${index + 1}`,\n source,\n sourceLabel: source,\n title,\n label,\n text,\n roleTags: roleTags.slice(0, QUICK_ACTION_MAX_TAGS),\n toolIntent,\n evidenceRefs,\n status: \"ready\"\n };\n}\n\nfunction normalizeQuickActionProviderPayload(payload = {}, context = {}, options = {}) {\n const suggestions = Array.isArray(payload.quickActionSuggestions)\n ? payload.quickActionSuggestions\n : [];\n const normalized = suggestions\n .map((suggestion, index) => normalizeSuggestion(suggestion, context, index, {\n fallbackSource: options.source || \"provider-ai\",\n expectedSource: options.expectedSource,\n role: context.player?.role\n }))\n .filter(Boolean)\n .slice(0, clampInteger(options.count ?? context.requestedCount, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT));\n if (!normalized.length) {\n throw new Error(\"quick_action suggestions were empty after safety validation\");\n }\n assertQuickActionSafe(normalized, { allowProviderSource: true });\n return normalized;\n}\n\nfunction buildQuickActionResponse(worldState = {}, payload = {}, context = {}, options = {}) {\n const suggestions = normalizeQuickActionProviderPayload(payload, context, {\n source: options.source || \"provider-ai\",\n expectedSource: options.expectedSource,\n count: options.count\n });\n const responseSource = suggestions.some((suggestion) => suggestion.source === \"provider-ai\")\n ? \"provider-ai\"\n : suggestions[0]?.source || \"mock-ai\";\n return {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n sessionId: cleanText(worldState.sessionId, \"\", 80),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n source: responseSource,\n status: \"ready\",\n stale: false,\n quickActionSuggestions: suggestions\n };\n}\n\nfunction assertQuickActionSafe(value, options = {}) {\n const serialized = JSON.stringify(value);\n if (SENSITIVE_QUICK_ACTION_PATTERN.test(serialized)) {\n throw new Error(\"quick action payload contains forbidden source text\");\n }\n return true;\n}\n\nmodule.exports = {\n QUICK_ACTION_MAX_COUNT,\n QUICK_ACTION_SCHEMA_VERSION,\n assertQuickActionSafe,\n buildLocalQuickActionResponse,\n buildLocalQuickActionSuggestions,\n buildQuickActionContext,\n buildQuickActionResponse,\n normalizeQuickActionProviderPayload,\n normalizeQuickActionRequest\n};\n"}, "140257f237e6e0f189b10b04bfa79750e49cde13": {"t": 1785753935.0029755, "n": 17829, "h": "66d98a7ac6bd3ce5", "c": "const { buildPlayerAiActorProfile } = require(\"./aiActorProfiles\");\nconst {\n assertResolverInputSafe,\n buildResolverInputContext\n} = require(\"./resolverInputContext\");\nconst { RESOLVER_INPUT_DOMAINS } = require(\"./resolverInputConfig\");\nconst { formatYearMonthPeriod } = require(\"./time\");\n\nconst QUICK_ACTION_SCHEMA_VERSION = \"s75.9-quick-actions.v1\";\nconst QUICK_ACTION_MAX_COUNT = 3;\nconst QUICK_ACTION_MAX_DRAFT_PREVIEW = 80;\nconst QUICK_ACTION_MAX_TEXT = 96;\nconst QUICK_ACTION_MAX_TITLE = 12;\nconst QUICK_ACTION_MAX_TAGS = 6;\nconst QUICK_ACTION_MAX_EVIDENCE_REFS = 4;\n\nconst QUICK_ACTION_SOURCES = Object.freeze([\"local-rule\", \"mock-ai\", \"provider-ai\"]);\nconst QUICK_ACTION_TOOL_INTENTS = Object.freeze([\n \"study\",\n \"exam\",\n \"patrol\",\n \"case\",\n \"memorial\",\n \"march\",\n \"court\",\n \"travel\",\n \"office\",\n \"generic\"\n]);\n\nconst SENSITIVE_QUICK_ACTION_PATTERN =\n /(SEALED_[A-Z0-9_]+|hidden[_ -]?(?:notes?|intent)|hidden\\s+(?:notes?|intent)|密档|私档|密札|密信|隐藏(?:意图|动机|事实|札记)|隐秘(?:意图|动机|事实)|raw[_ -]?(?:provider|audit|table|ledger|prompt|proposal|state|row)|\\b(?:statePatch|worldState|provider\\s+payload|provider\\s+proposal|rawSql|SQL|sqlite|server\\.)\\b|完整\\s*prompt|完整提示词|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|safe_search_(?:index|fts)|(?:geo|people)_[A-Za-z0-9_]+|office_(?!read\\b)[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|file:\\/\\/\\/?(?:[A-Za-z]:[\\\\/]|(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/)[^\\s\"'<>]+|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,})/i;\nconst RESULT_CLAIM_QUICK_ACTION_PATTERN =\n /(?:已|已经)(?:审结|结案|裁决|处置|平定|破获|赈济|授官|任命|罢免|定罪|升迁|提拔|推进完成|执行完毕)|(?:立即|直接)(?:任命|罢免|处死|定罪|结案|授官|平定|裁决)/i;\n\nconst ROLE_ALIASES = Object.freeze({\n emperor: \"emperor\",\n 皇帝: \"emperor\",\n scholar: \"scholar\",\n 书生: \"scholar\",\n general: \"general\",\n 将领: \"general\",\n magistrate: \"magistrate\",\n 地方官: \"magistrate\",\n minister: \"minister\",\n 大臣: \"minister\",\n official: \"official\",\n 官员: \"official\",\n 入仕官员: \"official\"\n});\n\nconst ROLE_ALLOWED_TOOL_INTENTS = Object.freeze({\n emperor: Object.freeze([\"court\", \"memorial\", \"office\", \"patrol\", \"generic\"]),\n scholar: Object.freeze([\"study\", \"exam\", \"travel\", \"generic\"]),\n general: Object.freeze([\"march\", \"patrol\", \"memorial\", \"travel\", \"generic\"]),\n magistrate: Object.freeze([\"case\", \"patrol\", \"memorial\", \"office\", \"generic\"]),\n minister: Object.freeze([\"memorial\", \"court\", \"office\", \"patrol\", \"generic\"]),\n official: Object.freeze([\"memorial\", \"office\", \"court\", \"patrol\", \"generic\"])\n});\n\nconst TOOL_GROUP_TO_INTENT = Object.freeze({\n study: \"study\",\n exam: \"exam\",\n judicial: \"case\",\n city_policy: \"patrol\",\n military: \"march\",\n diplomacy: \"travel\",\n ruler: \"court\",\n career: \"office\",\n report: \"memorial\",\n map: \"travel\",\n time: \"generic\",\n personal_action: \"generic\"\n});\n\nconst LOCAL_ROLE_SUGGESTIONS = Object.freeze({\n emperor: Object.freeze([\n Object.freeze({ id: \"emperor-edict\", title: \"宣旨\", label: \"宣旨\", text: \"宣旨整饬吏治,命中枢三日内具奏各省积弊。\", roleTags: Object.freeze([\"emperor\", \"edict\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"emperor-vermilion\", title: \"朱批\", label: \"朱批\", text: \"朱批近日奏折,询问钱粮、边防与民生三事。\", roleTags: Object.freeze([\"emperor\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"emperor-court\", title: \"召见群臣\", label: \"召见群臣\", text: \"召见群臣廷议,听取各部对当前局势的陈奏。\", roleTags: Object.freeze([\"emperor\", \"court\"]), toolIntent: \"court\" })\n ]),\n scholar: Object.freeze([\n Object.freeze({ id: \"scholar-study\", title: \"研读\", label: \"研读\", text: \"闭门研读经义,整理近日所得,准备下一场考试。\", roleTags: Object.freeze([\"scholar\", \"study\"]), toolIntent: \"study\" }),\n Object.freeze({ id: \"scholar-essay\", title: \"作文\", label: \"作文\", text: \"拟作一篇策论,请师友指出破题与立意得失。\", roleTags: Object.freeze([\"scholar\", \"essay\"]), toolIntent: \"study\" }),\n Object.freeze({ id: \"scholar-exam\", title: \"赴考\", label: \"赴考\", text: \"打听考期与贡院规矩,整备行装准备赴考。\", roleTags: Object.freeze([\"scholar\", \"exam\"]), toolIntent: \"exam\" })\n ]),\n general: Object.freeze([\n Object.freeze({ id: \"general-deploy\", title: \"遣将\", label: \"遣将\", text: \"遣将巡查前哨,回报粮道、军心与敌情虚实。\", roleTags: Object.freeze([\"general\", \"scout\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"general-camp\", title: \"巡营\", label: \"巡营\", text: \"亲自巡营,查点甲仗粮草,并安抚将士。\", roleTags: Object.freeze([\"general\", \"camp\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"general-report\", title: \"上战报\", label: \"上战报\", text: \"整理边情与军务得失,上战报请朝廷裁示。\", roleTags: Object.freeze([\"general\", \"report\"]), toolIntent: \"memorial\" })\n ]),\n magistrate: Object.freeze([\n Object.freeze({ id: \"magistrate-trial\", title: \"审案\", label: \"审案\", text: \"升堂审理积案,先核公开证词与案牍记录。\", roleTags: Object.freeze([\"magistrate\", \"case\"]), toolIntent: \"case\" }),\n Object.freeze({ id: \"magistrate-relief\", title: \"赈济\", label: \"赈济\", text: \"查问灾情与仓储,拟定赈济安民之策。\", roleTags: Object.freeze([\"magistrate\", \"relief\"]), toolIntent: \"patrol\" }),\n Object.freeze({ id: \"magistrate-dike\", title: \"修堤\", label: \"修堤\", text: \"召集里正与工匠,勘查河堤险段并筹措修缮。\", roleTags: Object.freeze([\"magistrate\", \"dike\"]), toolIntent: \"office\" })\n ]),\n minister: Object.freeze([\n Object.freeze({ id: \"minister-memorial\", title: \"上疏\", label: \"上疏\", text: \"上疏陈明时弊,提出可由朝廷裁量的三条办法。\", roleTags: Object.freeze([\"minister\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"minister-council\", title: \"会商\", label: \"会商\", text: \"约同僚会商部务,分辨轻重缓急后再行具奏。\", roleTags: Object.freeze([\"minister\", \"council\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"minister-docket\", title: \"查阅案牍\", label: \"查阅案牍\", text: \"查阅近日案牍与公文,摘出关涉民生与官声的要点。\", roleTags: Object.freeze([\"minister\", \"docket\"]), toolIntent: \"office\" })\n ]),\n official: Object.freeze([\n Object.freeze({ id: \"official-memorial\", title: \"上疏\", label: \"上疏\", text: \"上疏陈明任内见闻,请求朝廷明示处置章程。\", roleTags: Object.freeze([\"official\", \"memorial\"]), toolIntent: \"memorial\" }),\n Object.freeze({ id: \"official-council\", title: \"会商\", label: \"会商\", text: \"拜会同僚会商公事,先求稳妥可行之策。\", roleTags: Object.freeze([\"official\", \"council\"]), toolIntent: \"court\" }),\n Object.freeze({ id: \"official-docket\", title: \"查阅案牍\", label: \"查阅案牍\", text: \"查阅案牍与考成记录,整理下一步施政重点。\", roleTags: Object.freeze([\"official\", \"docket\"]), toolIntent: \"office\" })\n ])\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampInteger(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = 120) {\n if (typeof value !== \"string\") return fallback;\n const text = value.replace(/\\s+/g, \" \").trim();\n if (!text || SENSITIVE_QUICK_ACTION_PATTERN.test(text) || RESULT_CLAIM_QUICK_ACTION_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? text.slice(0, maxLength) : text;\n}\n\nfunction cleanTag(value) {\n const text = cleanText(String(value || \"\"), \"\", 32)\n .replace(/[^A-Za-z0-9_\\-\\u4e00-\\u9fa5]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return text || \"\";\n}\n\nfunction normalizeRole(role) {\n return ROLE_ALIASES[String(role || \"\").trim()] || \"official\";\n}\n\nfunction normalizeQuickActionRequest(body = {}) {\n const input = isPlainObject(body) ? body : {};\n return {\n page: cleanText(input.page, \"game\", 32) || \"game\",\n draftPreview: cleanText(input.draftPreview || input.currentDraftPreview, \"\", QUICK_ACTION_MAX_DRAFT_PREVIEW),\n count: clampInteger(input.count ?? input.limit, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT)\n };\n}\n\nfunction buildPlayerSummary(worldState = {}) {\n const player = worldState.player || {};\n return {\n name: cleanText(player.name, \"未题名\", 48),\n role: normalizeRole(player.role),\n roleLabel: cleanText(player.roleLabel || player.role, \"\", 48),\n position: cleanText(player.position || player.officeTitle, \"\", 80),\n examRank: cleanText(player.examRank || player.palaceRank, \"\", 40),\n officeTitle: cleanText(player.officeTitle, \"\", 80),\n countyName: cleanText(player.countyName, \"\", 80)\n };\n}\n\nfunction flattenEvidence(context = {}, limit = 12) {\n const evidence = [];\n for (const domain of RESOLVER_INPUT_DOMAINS) {\n for (const item of Array.isArray(context[domain]) ? context[domain] : []) {\n evidence.push({\n refId: item.refId,\n sourceView: item.sourceView,\n domain: item.domain,\n label: item.label,\n summary: item.summary,\n visibility: item.visibility,\n confidence: item.confidence,\n freshness: item.freshness\n });\n if (evidence.length >= limit) return evidence;\n }\n }\n return evidence;\n}\n\nfunction buildRouteViewFlags(context = {}, worldState = {}) {\n return {\n hasPublicEvidence: RESOLVER_INPUT_DOMAINS.some((domain) => Array.isArray(context[domain]) && context[domain].length > 0),\n hasMapContext: Array.isArray(context.map) && context.map.length > 0,\n hasEventArchive: Array.isArray(context.events) && context.events.length > 0,\n hasPeopleView: Array.isArray(context.people) && context.people.length > 0,\n hasOfficeView: Array.isArray(context.offices) && context.offices.length > 0,\n hasExamContext: Boolean(worldState.activeExam || worldState.player?.examRank || worldState.studyProfile)\n };\n}\n\nfunction buildToolCapabilities(actorProfile = {}) {\n const allowedGroups = Array.isArray(actorProfile.allowedToolGroups) ? actorProfile.allowedToolGroups : [];\n return allowedGroups\n .map((group) => {\n const intent = TOOL_GROUP_TO_INTENT[group] || \"generic\";\n return {\n group,\n toolIntent: intent,\n boundary: \"只可生成行动草稿;工具执行、proposal 裁决和状态写入均由服务器处理。\"\n };\n })\n .filter((item, index, source) => source.findIndex((other) => other.group === item.group) === index)\n .slice(0, 16);\n}\n\nfunction buildQuickActionContext(worldState = {}, request = {}) {\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const resolverContext = buildResolverInputContext(worldState, {\n includeSessionId: false,\n eventArchivePageSize: 12,\n sceneType: \"quick_action\",\n intentType: \"quick_action\",\n requestSummary: request.draftPreview || \"快捷行动建议\",\n actorProfile,\n domainCaps: {\n geography: 3,\n people: 3,\n offices: 3,\n economy: 2,\n military: 2,\n events: 4,\n intel: 2,\n player: 3,\n map: 3,\n memory: 2\n }\n });\n assertResolverInputSafe(resolverContext);\n const evidenceRefs = flattenEvidence(resolverContext, 12);\n const player = buildPlayerSummary(worldState);\n const context = {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n page: request.page || \"game\",\n requestedCount: request.count || QUICK_ACTION_MAX_COUNT,\n draftPreview: request.draftPreview || \"\",\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n date: {\n label: formatYearMonthPeriod(worldState),\n year: worldState.year,\n month: worldState.month,\n tenDayPeriod: worldState.tenDayPeriod\n },\n player,\n routeViewFlags: buildRouteViewFlags(resolverContext, worldState),\n toolCapabilities: buildToolCapabilities(actorProfile),\n evidenceRefs\n };\n assertQuickActionSafe(context, { allowProviderSource: true });\n return context;\n}\n\nfunction withSource(suggestion, source) {\n return {\n ...suggestion,\n source,\n sourceLabel: source,\n status: \"ready\",\n evidenceRefs: []\n };\n}\n\nfunction buildLocalQuickActionSuggestions(contextOrWorldState = {}, options = {}) {\n const source = QUICK_ACTION_SOURCES.includes(options.source) ? options.source : \"local-rule\";\n const player = contextOrWorldState.player?.role\n ? contextOrWorldState.player\n : buildPlayerSummary(contextOrWorldState);\n const role = normalizeRole(player.role);\n const limit = clampInteger(options.count ?? contextOrWorldState.requestedCount, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT);\n const base = LOCAL_ROLE_SUGGESTIONS[role] || LOCAL_ROLE_SUGGESTIONS.official;\n return base.slice(0, limit).map((suggestion) => withSource(suggestion, source));\n}\n\nfunction buildLocalQuickActionResponse(worldState = {}, request = {}, options = {}) {\n const context = options.context || buildQuickActionContext(worldState, request);\n const source = QUICK_ACTION_SOURCES.includes(options.source) ? options.source : \"local-rule\";\n const suggestions = buildLocalQuickActionSuggestions(context, {\n source,\n count: request.count || context.requestedCount\n });\n return {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n sessionId: cleanText(worldState.sessionId, \"\", 80),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n source,\n status: options.status || (source === \"local-rule\" ? \"fallback\" : \"ready\"),\n stale: false,\n fallbackReason: cleanText(options.fallbackReason, \"\", 80) || undefined,\n quickActionSuggestions: suggestions.map((suggestion, index) => normalizeSuggestion(\n suggestion,\n context,\n index,\n { fallbackSource: source, role: context.player?.role }\n )).filter(Boolean)\n };\n}\n\nfunction normalizeSource(value, fallbackSource = \"provider-ai\") {\n const source = String(value || \"\").trim();\n if (QUICK_ACTION_SOURCES.includes(source)) return source;\n return fallbackSource;\n}\n\nfunction normalizeToolIntent(value, role) {\n const intent = QUICK_ACTION_TOOL_INTENTS.includes(value) ? value : \"generic\";\n const allowed = ROLE_ALLOWED_TOOL_INTENTS[normalizeRole(role)] || ROLE_ALLOWED_TOOL_INTENTS.official;\n return allowed.includes(intent) ? intent : \"\";\n}\n\nfunction normalizeEvidenceRefs(refs, allowedRefIds) {\n if (!Array.isArray(refs)) return [];\n const accepted = [];\n for (const ref of refs) {\n const text = cleanText(String(ref || \"\"), \"\", 120);\n if (!text || !allowedRefIds.has(text) || accepted.includes(text)) continue;\n accepted.push(text);\n if (accepted.length >= QUICK_ACTION_MAX_EVIDENCE_REFS) break;\n }\n return accepted;\n}\n\nfunction normalizeSuggestion(suggestion, context = {}, index = 0, options = {}) {\n if (!isPlainObject(suggestion)) return null;\n const role = normalizeRole(options.role || context.player?.role);\n const source = normalizeSource(suggestion.source, options.fallbackSource || \"provider-ai\");\n if (options.expectedSource && source !== options.expectedSource) return null;\n const toolIntent = normalizeToolIntent(suggestion.toolIntent, role);\n if (!toolIntent) return null;\n\n const allowedRefIds = new Set((context.evidenceRefs || []).map((ref) => ref.refId).filter(Boolean));\n const title = cleanText(suggestion.title, \"\", QUICK_ACTION_MAX_TITLE);\n const label = cleanText(suggestion.label || suggestion.title, title, QUICK_ACTION_MAX_TITLE);\n const text = cleanText(suggestion.text, \"\", QUICK_ACTION_MAX_TEXT);\n if (!title || !label || !text) return null;\n\n const evidenceRefs = normalizeEvidenceRefs(suggestion.evidenceRefs, allowedRefIds);\n if (Array.isArray(suggestion.evidenceRefs) && suggestion.evidenceRefs.length > 0 && evidenceRefs.length === 0) {\n return null;\n }\n\n const roleTags = Array.isArray(suggestion.roleTags)\n ? suggestion.roleTags.map(cleanTag).filter(Boolean)\n : [];\n if (!roleTags.includes(role)) roleTags.unshift(role);\n\n return {\n id: cleanTag(suggestion.id) || `quick-${source}-${role}-${index + 1}`,\n source,\n sourceLabel: source,\n title,\n label,\n text,\n roleTags: roleTags.slice(0, QUICK_ACTION_MAX_TAGS),\n toolIntent,\n evidenceRefs,\n status: \"ready\"\n };\n}\n\nfunction normalizeQuickActionProviderPayload(payload = {}, context = {}, options = {}) {\n const suggestions = Array.isArray(payload.quickActionSuggestions)\n ? payload.quickActionSuggestions\n : [];\n const normalized = suggestions\n .map((suggestion, index) => normalizeSuggestion(suggestion, context, index, {\n fallbackSource: options.source || \"provider-ai\",\n expectedSource: options.expectedSource,\n role: context.player?.role\n }))\n .filter(Boolean)\n .slice(0, clampInteger(options.count ?? context.requestedCount, 1, QUICK_ACTION_MAX_COUNT, QUICK_ACTION_MAX_COUNT));\n if (!normalized.length) {\n throw new Error(\"quick_action suggestions were empty after safety validation\");\n }\n assertQuickActionSafe(normalized, { allowProviderSource: true });\n return normalized;\n}\n\nfunction buildQuickActionResponse(worldState = {}, payload = {}, context = {}, options = {}) {\n const suggestions = normalizeQuickActionProviderPayload(payload, context, {\n source: options.source || \"provider-ai\",\n expectedSource: options.expectedSource,\n count: options.count\n });\n const responseSource = suggestions.some((suggestion) => suggestion.source === \"provider-ai\")\n ? \"provider-ai\"\n : suggestions[0]?.source || \"mock-ai\";\n return {\n schemaVersion: QUICK_ACTION_SCHEMA_VERSION,\n sessionId: cleanText(worldState.sessionId, \"\", 80),\n generatedAtTurn: clampInteger(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n source: responseSource,\n status: \"ready\",\n stale: false,\n quickActionSuggestions: suggestions\n };\n}\n\nfunction assertQuickActionSafe(value, options = {}) {\n const serialized = JSON.stringify(value);\n if (SENSITIVE_QUICK_ACTION_PATTERN.test(serialized)) {\n throw new Error(\"quick action payload contains forbidden source text\");\n }\n return true;\n}\n\nmodule.exports = {\n QUICK_ACTION_MAX_COUNT,\n QUICK_ACTION_SCHEMA_VERSION,\n assertQuickActionSafe,\n buildLocalQuickActionResponse,\n buildLocalQuickActionSuggestions,\n buildQuickActionContext,\n buildQuickActionResponse,\n normalizeQuickActionProviderPayload,\n normalizeQuickActionRequest\n};\n"}}, "src/game/informationPanelPage.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745548.2071075, "n": 63513, "h": "5ce945e87b7cd8f8", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildPlayerEnterpriseView } = require(\"./industryLedger\");\nconst { buildBankingView } = require(\"./bankingLedger\");\nconst { buildNpcCohortView } = require(\"./npcCohorts\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"player-enterprises\",\n \"banking\",\n \"npc-cohorts\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|bankingLedger|npcCohortLedger|conflictOfInterestLedger|productionRecipe|laborCohort|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n enterprise: \"产业\",\n production_cycle: \"产程\",\n labor_row: \"劳力\",\n enterprise_stock: \"库存\",\n industry_event: \"产业事件\",\n banking_market: \"钱庄市面\",\n bank_house: \"钱庄总号\",\n bank_branch: \"钱庄分号\",\n bank_deposit: \"存款\",\n bank_loan: \"贷款\",\n bank_bill: \"汇票\",\n bank_reserve: \"准备金\",\n bank_risk_event: \"钱庄风险\",\n bank_license: \"钱庄牌照\",\n bank_action: \"钱庄动作\",\n npc_cohort: \"商帮工群\",\n semi_named_actor: \"半具名代表\",\n named_promotion: \"具名升格\",\n market_reaction: \"市场反应\",\n market_shadow: \"市场影子\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"player-enterprises\": \"enterprise\",\n banking: \"bank_branch\",\n \"npc-cohorts\": \"npc_cohort\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"player-enterprises\": \"risk\",\n banking: \"risk\",\n \"npc-cohorts\": \"pressure\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildPlayerEnterpriseRows(worldState, views) {\n const enterprises = views.playerEnterpriseView || {};\n const rows = [];\n for (const enterprise of Array.isArray(enterprises.enterpriseRows) ? enterprises.enterpriseRows : []) {\n rows.push(makePageRow({\n id: `enterprise:${enterprise.enterpriseId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.enterpriseRows\",\n kind: \"enterprise\",\n kindLabel: \"产业\",\n filterValue: \"enterprise\",\n title: enterprise.name,\n meta: `${enterprise.enterpriseTypeLabel || \"产业\"} · ${enterprise.statusLabel || \"已登记\"} · ${enterprise.cityName || \"本地\"}`,\n summary: enterprise.publicSummary,\n extra: compactList([\n enterprise.accountantLabel,\n enterprise.permitStatusLabel,\n ...(enterprise.channelHints || [])\n ], \"\"),\n metrics: [\n [\"品相\", enterprise.qualityScore],\n [\"维护\", `${numberValue(enterprise.maintenanceReserveCopper, 0)}文`],\n [\"劳力\", enterprise.laborSummary?.statusLabel || \"未雇工\"],\n [\"状态\", enterprise.statusLabel]\n ],\n tags: [enterprise.enterpriseTypeLabel, enterprise.industryLabel, ...(enterprise.riskTags || []), ...(enterprise.conflictFlags || [])],\n cityId: enterprise.cityId,\n cityName: enterprise.cityName,\n pressure: Math.max(numberValue(enterprise.maintenanceRequiredCopper, 0) - numberValue(enterprise.maintenanceReserveCopper, 0), 0) / 100,\n risk: (enterprise.conflictFlags || []).length ? 82 : (enterprise.status === \"halted\" || enterprise.status === \"cancelled\" ? 74 : 42),\n turn: enterprise.updatedTurn,\n sortValue: numberValue(enterprise.updatedTurn)\n }));\n }\n for (const cycle of Array.isArray(enterprises.productionRows) ? enterprises.productionRows : []) {\n rows.push(makePageRow({\n id: `production-cycle:${cycle.cycleId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.productionRows\",\n kind: \"production_cycle\",\n kindLabel: \"产程\",\n filterValue: \"production_cycle\",\n title: `${cycle.recipeLabel}产程`,\n meta: `${cycle.statusLabel || \"营作\"} · 到期第${cycle.dueTurn ?? \"?\"}回`,\n summary: cycle.publicSummary,\n extra: compactList((cycle.outputGoods || []).map((goods) => `${goods.displayName}${goods.quantity}${goods.unitLabel || \"\"}`), \"\"),\n metrics: [\n [\"劳力\", `${numberValue(cycle.assignedLabor, 0)}/${numberValue(cycle.requiredLabor, 0)}`],\n [\"维护\", `${numberValue(cycle.maintenanceCostCopper, 0)}文`],\n [\"品相\", cycle.qualityScore],\n [\"到期\", cycle.dueTurn === null || cycle.dueTurn === undefined ? \"未定\" : `第${cycle.dueTurn}回`]\n ],\n tags: [cycle.recipeLabel, cycle.statusLabel, ...(cycle.riskTags || [])],\n risk: cycle.status === \"failed\" ? 86 : cycle.status === \"in_progress\" ? 54 : 32,\n turn: cycle.resolvedTurn || cycle.startedTurn,\n sortValue: cycle.status === \"in_progress\" ? 80 : numberValue(cycle.resolvedTurn || cycle.startedTurn)\n }));\n }\n for (const labor of Array.isArray(enterprises.laborRows) ? enterprises.laborRows : []) {\n rows.push(makePageRow({\n id: `enterprise-labor:${labor.laborId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.laborRows\",\n kind: \"labor_row\",\n kindLabel: \"劳力\",\n filterValue: \"labor_row\",\n title: `${labor.skillLabel} ${labor.workerCount}人`,\n meta: labor.statusLabel,\n summary: labor.publicSummary,\n metrics: [\n [\"人数\", labor.workerCount],\n [\"士气\", labor.morale],\n [\"工钱\", `${numberValue(labor.wageCopperPerWorker, 0)}文/人`]\n ],\n tags: [labor.skillLabel, labor.statusLabel],\n risk: numberValue(labor.morale, 0) < 45 ? 72 : 30,\n turn: labor.updatedTurn,\n sortValue: numberValue(labor.updatedTurn)\n }));\n }\n for (const stock of Array.isArray(enterprises.stockRows) ? enterprises.stockRows : []) {\n rows.push(makePageRow({\n id: `enterprise-stock:${stock.stockId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.stockRows\",\n kind: \"enterprise_stock\",\n kindLabel: \"库存\",\n filterValue: \"enterprise_stock\",\n title: `${stock.displayName}${stock.statusLabel}`,\n meta: `${stock.quantity}${stock.unitLabel || \"\"} · ${stock.permitStatusLabel || \"普通货物\"}`,\n summary: stock.publicSummary,\n extra: stock.sourceLabel,\n metrics: [\n [\"数量\", `${numberValue(stock.quantity, 0)}${stock.unitLabel || \"\"}`],\n [\"状态\", stock.statusLabel],\n [\"法度\", stock.legalStatus]\n ],\n tags: [stock.displayName, stock.statusLabel, stock.permitStatusLabel],\n risk: stock.permitStatusLabel === \"需牌照核验\" ? 76 : 28,\n sortValue: numberValue(stock.quantity)\n }));\n }\n for (const event of Array.isArray(enterprises.eventRows) ? enterprises.eventRows : []) {\n rows.push(makePageRow({\n id: `industry-event:${event.eventId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.eventRows\",\n kind: \"industry_event\",\n kindLabel: \"产业事件\",\n filterValue: \"industry_event\",\n title: event.title,\n meta: `${event.dateLabel || \"\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"回数\", `第${event.turn ?? 0}回`],\n [\"等级\", event.severity]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"warning\" ? 78 : 36,\n turn: event.turn,\n dateLabel: event.dateLabel,\n sortValue: numberValue(event.turn)\n }));\n }\n return rows;\n}\n\nfunction buildBankingRows(worldState, views) {\n const banking = views.bankingView || {};\n const rows = [];\n for (const market of Array.isArray(banking.marketRows) ? banking.marketRows : []) {\n rows.push(makePageRow({\n id: `banking-market:${market.cityId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.marketRows\",\n kind: \"banking_market\",\n kindLabel: \"钱庄市面\",\n filterValue: \"banking_market\",\n title: `${market.cityName}钱庄市面`,\n meta: `存款${market.depositPotentialLabel || \"\"} · 放款${market.loanDemandLabel || \"\"}`,\n summary: market.publicSummary,\n metrics: [\n [\"存款潜势\", market.depositPotentialLabel],\n [\"放款需求\", market.loanDemandLabel],\n [\"汇票需求\", market.billDemandLabel],\n [\"坏账压力\", market.defaultStressLabel]\n ],\n tags: [\"钱庄市面\", market.defaultStressLabel],\n cityId: market.cityId,\n pressure: market.marketPressure,\n risk: market.defaultStressScore,\n sortValue: market.defaultStressScore\n }));\n }\n for (const house of Array.isArray(banking.houseRows) ? banking.houseRows : []) {\n rows.push(makePageRow({\n id: `bank-house:${house.houseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.houseRows\",\n kind: \"bank_house\",\n kindLabel: \"钱庄总号\",\n filterValue: \"bank_house\",\n title: house.name,\n meta: `${house.statusLabel || \"营运\"} · ${house.licenseStatusLabel || \"牌照待核\"}`,\n summary: house.publicSummary,\n metrics: [\n [\"声誉\", house.reputation],\n [\"清查风险\", house.auditRisk],\n [\"分号\", Array.isArray(house.branchIds) ? house.branchIds.length : 0]\n ],\n tags: [house.statusLabel, house.licenseStatusLabel, ...(house.riskTags || [])],\n risk: house.auditRisk,\n turn: house.updatedTurn,\n sortValue: house.auditRisk\n }));\n }\n for (const branch of Array.isArray(banking.branchRows) ? banking.branchRows : []) {\n rows.push(makePageRow({\n id: `bank-branch:${branch.branchId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.branchRows\",\n kind: \"bank_branch\",\n kindLabel: \"钱庄分号\",\n filterValue: \"bank_branch\",\n title: branch.branchName,\n meta: `${branch.cityName || \"本地\"} · ${branch.statusLabel || \"开兑\"} · ${branch.liquidityLabel || \"准备金未详\"}`,\n summary: branch.publicSummary,\n metrics: [\n [\"准备金\", branch.reserveLabel],\n [\"存款负债\", branch.depositLabel],\n [\"放款\", branch.loanLabel],\n [\"汇票\", branch.billLabel],\n [\"挤兑风险\", branch.runRiskLabel]\n ],\n tags: [branch.statusLabel, branch.licenseStatusLabel, branch.liquidityLabel, branch.runRiskLabel, ...(branch.riskTags || [])],\n cityId: branch.cityId,\n pressure: branch.cityProsperityLink,\n risk: branch.runRisk,\n turn: branch.updatedTurn,\n sortValue: branch.runRisk\n }));\n }\n for (const deposit of Array.isArray(banking.depositRows) ? banking.depositRows : []) {\n rows.push(makePageRow({\n id: `bank-deposit:${deposit.depositId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.depositRows\",\n kind: \"bank_deposit\",\n kindLabel: \"存款\",\n filterValue: \"bank_deposit\",\n title: `${deposit.depositorLabel || \"存户\"}存款`,\n meta: `${deposit.amountLabel || \"金额未详\"} · ${deposit.statusLabel || \"在账\"}`,\n summary: deposit.publicSummary,\n metrics: [\n [\"存款\", deposit.amountLabel],\n [\"状态\", deposit.statusLabel]\n ],\n tags: [deposit.depositorKind, deposit.statusLabel, \"负债\"],\n risk: deposit.status === \"frozen\" ? 66 : 26,\n turn: deposit.updatedTurn,\n sortValue: numberValue(deposit.updatedTurn)\n }));\n }\n for (const loan of Array.isArray(banking.loanRows) ? banking.loanRows : []) {\n rows.push(makePageRow({\n id: `bank-loan:${loan.loanId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.loanRows\",\n kind: \"bank_loan\",\n kindLabel: \"贷款\",\n filterValue: \"bank_loan\",\n title: `${loan.borrowerLabel || \"借户\"}贷款`,\n meta: `${loan.outstandingLabel || loan.principalLabel || \"欠额未详\"} · ${loan.statusLabel || \"在账\"}`,\n summary: loan.publicSummary,\n metrics: [\n [\"本金\", loan.principalLabel],\n [\"未清\", loan.outstandingLabel],\n [\"风险\", loan.riskLabel],\n [\"到期\", loan.dueTurn === null || loan.dueTurn === undefined ? \"未定\" : `第${loan.dueTurn}回`]\n ],\n tags: [loan.borrowerKind, loan.statusLabel, loan.riskLabel, ...(loan.riskTags || [])],\n risk: loan.riskScore,\n turn: loan.updatedTurn,\n sortValue: loan.riskScore\n }));\n }\n for (const bill of Array.isArray(banking.billRows) ? banking.billRows : []) {\n rows.push(makePageRow({\n id: `bank-bill:${bill.billId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.billRows\",\n kind: \"bank_bill\",\n kindLabel: \"汇票\",\n filterValue: \"bank_bill\",\n title: `${bill.originCityName || \"本地\"}汇票`,\n meta: `${bill.targetCityName || \"兑地\"} · ${bill.amountLabel || \"金额未详\"} · ${bill.statusLabel || \"在途\"}`,\n summary: bill.publicSummary,\n metrics: [\n [\"票面\", bill.amountLabel],\n [\"票费\", bill.feeLabel],\n [\"状态\", bill.statusLabel]\n ],\n tags: [bill.statusLabel, ...(bill.riskTags || [])],\n cityId: bill.originCityId,\n risk: bill.status === \"defaulted\" ? 86 : bill.status === \"forged_review\" ? 76 : 36,\n turn: bill.updatedTurn,\n sortValue: numberValue(bill.updatedTurn)\n }));\n }\n for (const reserve of Array.isArray(banking.reserveRows) ? banking.reserveRows : []) {\n rows.push(makePageRow({\n id: `bank-reserve:${reserve.snapshotId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.reserveRows\",\n kind: \"bank_reserve\",\n kindLabel: \"准备金\",\n filterValue: \"bank_reserve\",\n title: \"准备金留痕\",\n meta: `${reserve.reserveLabel || \"准备金未详\"} · ${reserve.liquidityLabel || \"流动性未详\"}`,\n summary: reserve.publicSummary,\n metrics: [\n [\"准备金\", reserve.reserveLabel],\n [\"存款负债\", reserve.depositLabel],\n [\"流动性\", reserve.liquidityLabel],\n [\"挤兑\", reserve.runRiskLabel]\n ],\n tags: [reserve.liquidityLabel, reserve.runRiskLabel],\n risk: reserve.runRisk,\n turn: reserve.turn,\n dateLabel: reserve.dateLabel,\n sortValue: reserve.runRisk\n }));\n }\n for (const event of Array.isArray(banking.riskEventRows) ? banking.riskEventRows : []) {\n rows.push(makePageRow({\n id: `bank-risk:${event.eventId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.riskEventRows\",\n kind: \"bank_risk_event\",\n kindLabel: \"钱庄风险\",\n filterValue: \"bank_risk_event\",\n title: event.title,\n meta: `${event.statusLabel || event.status || \"已记\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"等级\", event.severity],\n [\"状态\", event.statusLabel],\n [\"回合\", `第${event.generatedAtTurn ?? 0}回`]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38,\n turn: event.generatedAtTurn,\n dateLabel: event.dateLabel,\n sortValue: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38\n }));\n }\n for (const license of Array.isArray(banking.licenseRows) ? banking.licenseRows : []) {\n rows.push(makePageRow({\n id: `bank-license:${license.licenseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.licenseRows\",\n kind: \"bank_license\",\n kindLabel: \"钱庄牌照\",\n filterValue: \"bank_license\",\n title: \"钱庄牌照登记\",\n meta: license.statusLabel,\n summary: license.publicSummary,\n metrics: [\n [\"状态\", license.statusLabel],\n [\"来源\", license.sourcePolicyRef ? \"城市政策\" : \"清查\"]\n ],\n tags: [license.statusLabel, license.conflictOfInterestRef ? \"官商复核\" : \"\"],\n cityId: license.cityId,\n risk: license.conflictOfInterestRef ? 58 : 32,\n turn: license.updatedTurn,\n sortValue: numberValue(license.updatedTurn)\n }));\n }\n for (const action of Array.isArray(banking.actionRows) ? banking.actionRows : []) {\n rows.push(makePageRow({\n id: `bank-action:${action.actionId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.actionRows\",\n kind: \"bank_action\",\n kindLabel: \"钱庄动作\",\n filterValue: \"bank_action\",\n title: action.actionLabel,\n meta: action.accepted ? \"已受理\" : \"已拒绝\",\n summary: action.publicSummary,\n metrics: [\n [\"状态\", action.accepted ? \"已受理\" : \"已拒绝\"],\n [\"忽略字段\", Array.isArray(action.ignoredClientFields) ? action.ignoredClientFields.length : 0]\n ],\n tags: [action.actionType, action.accepted ? \"accepted\" : \"rejected\"],\n risk: action.accepted ? 36 : 70,\n turn: action.turn,\n dateLabel: action.dateLabel,\n sortValue: numberValue(action.turn)\n }));\n }\n return rows;\n}\n\nfunction buildNpcCohortRows(worldState, views) {\n const cohortView = views.npcCohortView || {};\n const rows = [];\n for (const cohort of Array.isArray(cohortView.cohortRows) ? cohortView.cohortRows : []) {\n rows.push(makePageRow({\n id: `npc-cohort:${cohort.cohortId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.cohortRows\",\n kind: \"npc_cohort\",\n filterValue: \"npc_cohort\",\n title: `${cohort.homeCityName || \"\"}${cohort.displayName || \"商帮工群\"}`,\n meta: `${cohort.statusLabel || \"已登记\"} · ${cohort.cohortType || \"cohort\"}`,\n summary: cohort.publicSummary,\n metrics: [\n [\"人数\", cohort.population],\n [\"满意\", cohort.satisfaction],\n [\"影响\", cohort.influence],\n [\"压力\", cohort.pressureScore]\n ],\n tags: [cohort.statusLabel, ...(cohort.goodsLabels || []), ...(cohort.tags || [])],\n cityId: cohort.homeCityId,\n pressure: cohort.pressureScore,\n risk: cohort.pressureScore,\n turn: cohort.lastUpdatedTurn,\n sortValue: cohort.pressureScore\n }));\n }\n for (const actor of Array.isArray(cohortView.semiNamedRows) ? cohortView.semiNamedRows : []) {\n rows.push(makePageRow({\n id: `semi-named:${actor.actorId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.semiNamedRows\",\n kind: \"semi_named_actor\",\n filterValue: \"semi_named_actor\",\n title: actor.displayName,\n meta: `${actor.roleLabel || \"代表\"} · ${actor.cityName || \"本地\"}`,\n summary: actor.publicSummary,\n metrics: [\n [\"压力\", actor.pressureScore],\n [\"影响\", actor.influence],\n [\"信任代理\", actor.trustProxy],\n [\"升格\", actor.upgradeEligible ? \"可升格\" : \"观察\"]\n ],\n tags: actor.tags || [],\n cityId: actor.cityId,\n pressure: actor.pressureScore,\n risk: actor.upgradeEligible ? 78 : actor.pressureScore,\n turn: actor.lastUpdatedTurn,\n sortValue: actor.pressureScore\n }));\n }\n for (const promotion of Array.isArray(cohortView.namedPromotionRows) ? cohortView.namedPromotionRows : []) {\n rows.push(makePageRow({\n id: `named-promotion:${promotion.promotionId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.namedPromotionRows\",\n kind: \"named_promotion\",\n filterValue: \"named_promotion\",\n title: promotion.displayName,\n meta: `${promotion.status || \"promoted\"} · ${promotion.cityName || \"\"}`,\n summary: promotion.publicSummary,\n metrics: [\n [\"NPC\", promotion.npcId],\n [\"回合\", promotion.promotedTurn]\n ],\n tags: promotion.tags || [],\n pressure: 80,\n risk: 66,\n turn: promotion.promotedTurn,\n sortValue: promotion.promotedTurn\n }));\n }\n for (const reaction of Array.isArray(cohortView.marketReactionRows) ? cohortView.marketReactionRows : []) {\n rows.push(makePageRow({\n id: `market-reaction:${reaction.reactionId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.marketReactionRows\",\n kind: \"market_reaction\",\n filterValue: \"market_reaction\",\n title: reaction.reasonLabel,\n meta: `${reaction.cityName || \"本地\"} · 压力${reaction.pressureDelta ?? 0}`,\n summary: reaction.publicSummary,\n metrics: [\n [\"压力变动\", reaction.pressureDelta],\n [\"需求\", reaction.demandDelta],\n [\"供给\", reaction.supplyDelta]\n ],\n tags: [...(reaction.goodsLabels || []), ...(reaction.marketCategories || [])],\n cityId: reaction.cityId,\n pressure: Math.abs(numberValue(reaction.pressureDelta)),\n risk: Math.abs(numberValue(reaction.pressureDelta)) * 10,\n turn: reaction.createdTurn,\n sortValue: Math.abs(numberValue(reaction.pressureDelta))\n }));\n }\n for (const shadow of Array.isArray(cohortView.shadowActorRows) ? cohortView.shadowActorRows : []) {\n rows.push(makePageRow({\n id: `market-shadow:${shadow.shadowId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.shadowActorRows\",\n kind: \"market_shadow\",\n filterValue: \"market_shadow\",\n title: shadow.label,\n meta: `${shadow.cityName || \"本地\"} · ${shadow.estimatedScale || \"shadow\"}`,\n summary: shadow.publicSummary,\n metrics: [[\"压力\", shadow.pressureDelta]],\n tags: shadow.marketCategories || [],\n cityId: shadow.cityId,\n pressure: Math.abs(numberValue(shadow.pressureDelta)),\n risk: Math.abs(numberValue(shadow.pressureDelta)) * 10,\n turn: shadow.lastUpdatedTurn,\n sortValue: Math.abs(numberValue(shadow.pressureDelta))\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"player-enterprises\") return buildPlayerEnterpriseRows(worldState, views);\n if (tabId === \"banking\") return buildBankingRows(worldState, views);\n if (tabId === \"npc-cohorts\") return buildNpcCohortRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState),\n playerEnterpriseView: views.playerEnterpriseView || buildPlayerEnterpriseView(worldState),\n bankingView: views.bankingView || buildBankingView(worldState),\n npcCohortView: views.npcCohortView || buildNpcCohortView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "4fee03106db37fd9558811ef943db46aa28615a7": {"t": 1785753934.717401, "n": 63513, "h": "5ce945e87b7cd8f8", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildPlayerEnterpriseView } = require(\"./industryLedger\");\nconst { buildBankingView } = require(\"./bankingLedger\");\nconst { buildNpcCohortView } = require(\"./npcCohorts\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"player-enterprises\",\n \"banking\",\n \"npc-cohorts\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|bankingLedger|npcCohortLedger|conflictOfInterestLedger|productionRecipe|laborCohort|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n enterprise: \"产业\",\n production_cycle: \"产程\",\n labor_row: \"劳力\",\n enterprise_stock: \"库存\",\n industry_event: \"产业事件\",\n banking_market: \"钱庄市面\",\n bank_house: \"钱庄总号\",\n bank_branch: \"钱庄分号\",\n bank_deposit: \"存款\",\n bank_loan: \"贷款\",\n bank_bill: \"汇票\",\n bank_reserve: \"准备金\",\n bank_risk_event: \"钱庄风险\",\n bank_license: \"钱庄牌照\",\n bank_action: \"钱庄动作\",\n npc_cohort: \"商帮工群\",\n semi_named_actor: \"半具名代表\",\n named_promotion: \"具名升格\",\n market_reaction: \"市场反应\",\n market_shadow: \"市场影子\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"player-enterprises\": \"enterprise\",\n banking: \"bank_branch\",\n \"npc-cohorts\": \"npc_cohort\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"player-enterprises\": \"risk\",\n banking: \"risk\",\n \"npc-cohorts\": \"pressure\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildPlayerEnterpriseRows(worldState, views) {\n const enterprises = views.playerEnterpriseView || {};\n const rows = [];\n for (const enterprise of Array.isArray(enterprises.enterpriseRows) ? enterprises.enterpriseRows : []) {\n rows.push(makePageRow({\n id: `enterprise:${enterprise.enterpriseId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.enterpriseRows\",\n kind: \"enterprise\",\n kindLabel: \"产业\",\n filterValue: \"enterprise\",\n title: enterprise.name,\n meta: `${enterprise.enterpriseTypeLabel || \"产业\"} · ${enterprise.statusLabel || \"已登记\"} · ${enterprise.cityName || \"本地\"}`,\n summary: enterprise.publicSummary,\n extra: compactList([\n enterprise.accountantLabel,\n enterprise.permitStatusLabel,\n ...(enterprise.channelHints || [])\n ], \"\"),\n metrics: [\n [\"品相\", enterprise.qualityScore],\n [\"维护\", `${numberValue(enterprise.maintenanceReserveCopper, 0)}文`],\n [\"劳力\", enterprise.laborSummary?.statusLabel || \"未雇工\"],\n [\"状态\", enterprise.statusLabel]\n ],\n tags: [enterprise.enterpriseTypeLabel, enterprise.industryLabel, ...(enterprise.riskTags || []), ...(enterprise.conflictFlags || [])],\n cityId: enterprise.cityId,\n cityName: enterprise.cityName,\n pressure: Math.max(numberValue(enterprise.maintenanceRequiredCopper, 0) - numberValue(enterprise.maintenanceReserveCopper, 0), 0) / 100,\n risk: (enterprise.conflictFlags || []).length ? 82 : (enterprise.status === \"halted\" || enterprise.status === \"cancelled\" ? 74 : 42),\n turn: enterprise.updatedTurn,\n sortValue: numberValue(enterprise.updatedTurn)\n }));\n }\n for (const cycle of Array.isArray(enterprises.productionRows) ? enterprises.productionRows : []) {\n rows.push(makePageRow({\n id: `production-cycle:${cycle.cycleId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.productionRows\",\n kind: \"production_cycle\",\n kindLabel: \"产程\",\n filterValue: \"production_cycle\",\n title: `${cycle.recipeLabel}产程`,\n meta: `${cycle.statusLabel || \"营作\"} · 到期第${cycle.dueTurn ?? \"?\"}回`,\n summary: cycle.publicSummary,\n extra: compactList((cycle.outputGoods || []).map((goods) => `${goods.displayName}${goods.quantity}${goods.unitLabel || \"\"}`), \"\"),\n metrics: [\n [\"劳力\", `${numberValue(cycle.assignedLabor, 0)}/${numberValue(cycle.requiredLabor, 0)}`],\n [\"维护\", `${numberValue(cycle.maintenanceCostCopper, 0)}文`],\n [\"品相\", cycle.qualityScore],\n [\"到期\", cycle.dueTurn === null || cycle.dueTurn === undefined ? \"未定\" : `第${cycle.dueTurn}回`]\n ],\n tags: [cycle.recipeLabel, cycle.statusLabel, ...(cycle.riskTags || [])],\n risk: cycle.status === \"failed\" ? 86 : cycle.status === \"in_progress\" ? 54 : 32,\n turn: cycle.resolvedTurn || cycle.startedTurn,\n sortValue: cycle.status === \"in_progress\" ? 80 : numberValue(cycle.resolvedTurn || cycle.startedTurn)\n }));\n }\n for (const labor of Array.isArray(enterprises.laborRows) ? enterprises.laborRows : []) {\n rows.push(makePageRow({\n id: `enterprise-labor:${labor.laborId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.laborRows\",\n kind: \"labor_row\",\n kindLabel: \"劳力\",\n filterValue: \"labor_row\",\n title: `${labor.skillLabel} ${labor.workerCount}人`,\n meta: labor.statusLabel,\n summary: labor.publicSummary,\n metrics: [\n [\"人数\", labor.workerCount],\n [\"士气\", labor.morale],\n [\"工钱\", `${numberValue(labor.wageCopperPerWorker, 0)}文/人`]\n ],\n tags: [labor.skillLabel, labor.statusLabel],\n risk: numberValue(labor.morale, 0) < 45 ? 72 : 30,\n turn: labor.updatedTurn,\n sortValue: numberValue(labor.updatedTurn)\n }));\n }\n for (const stock of Array.isArray(enterprises.stockRows) ? enterprises.stockRows : []) {\n rows.push(makePageRow({\n id: `enterprise-stock:${stock.stockId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.stockRows\",\n kind: \"enterprise_stock\",\n kindLabel: \"库存\",\n filterValue: \"enterprise_stock\",\n title: `${stock.displayName}${stock.statusLabel}`,\n meta: `${stock.quantity}${stock.unitLabel || \"\"} · ${stock.permitStatusLabel || \"普通货物\"}`,\n summary: stock.publicSummary,\n extra: stock.sourceLabel,\n metrics: [\n [\"数量\", `${numberValue(stock.quantity, 0)}${stock.unitLabel || \"\"}`],\n [\"状态\", stock.statusLabel],\n [\"法度\", stock.legalStatus]\n ],\n tags: [stock.displayName, stock.statusLabel, stock.permitStatusLabel],\n risk: stock.permitStatusLabel === \"需牌照核验\" ? 76 : 28,\n sortValue: numberValue(stock.quantity)\n }));\n }\n for (const event of Array.isArray(enterprises.eventRows) ? enterprises.eventRows : []) {\n rows.push(makePageRow({\n id: `industry-event:${event.eventId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.eventRows\",\n kind: \"industry_event\",\n kindLabel: \"产业事件\",\n filterValue: \"industry_event\",\n title: event.title,\n meta: `${event.dateLabel || \"\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"回数\", `第${event.turn ?? 0}回`],\n [\"等级\", event.severity]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"warning\" ? 78 : 36,\n turn: event.turn,\n dateLabel: event.dateLabel,\n sortValue: numberValue(event.turn)\n }));\n }\n return rows;\n}\n\nfunction buildBankingRows(worldState, views) {\n const banking = views.bankingView || {};\n const rows = [];\n for (const market of Array.isArray(banking.marketRows) ? banking.marketRows : []) {\n rows.push(makePageRow({\n id: `banking-market:${market.cityId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.marketRows\",\n kind: \"banking_market\",\n kindLabel: \"钱庄市面\",\n filterValue: \"banking_market\",\n title: `${market.cityName}钱庄市面`,\n meta: `存款${market.depositPotentialLabel || \"\"} · 放款${market.loanDemandLabel || \"\"}`,\n summary: market.publicSummary,\n metrics: [\n [\"存款潜势\", market.depositPotentialLabel],\n [\"放款需求\", market.loanDemandLabel],\n [\"汇票需求\", market.billDemandLabel],\n [\"坏账压力\", market.defaultStressLabel]\n ],\n tags: [\"钱庄市面\", market.defaultStressLabel],\n cityId: market.cityId,\n pressure: market.marketPressure,\n risk: market.defaultStressScore,\n sortValue: market.defaultStressScore\n }));\n }\n for (const house of Array.isArray(banking.houseRows) ? banking.houseRows : []) {\n rows.push(makePageRow({\n id: `bank-house:${house.houseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.houseRows\",\n kind: \"bank_house\",\n kindLabel: \"钱庄总号\",\n filterValue: \"bank_house\",\n title: house.name,\n meta: `${house.statusLabel || \"营运\"} · ${house.licenseStatusLabel || \"牌照待核\"}`,\n summary: house.publicSummary,\n metrics: [\n [\"声誉\", house.reputation],\n [\"清查风险\", house.auditRisk],\n [\"分号\", Array.isArray(house.branchIds) ? house.branchIds.length : 0]\n ],\n tags: [house.statusLabel, house.licenseStatusLabel, ...(house.riskTags || [])],\n risk: house.auditRisk,\n turn: house.updatedTurn,\n sortValue: house.auditRisk\n }));\n }\n for (const branch of Array.isArray(banking.branchRows) ? banking.branchRows : []) {\n rows.push(makePageRow({\n id: `bank-branch:${branch.branchId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.branchRows\",\n kind: \"bank_branch\",\n kindLabel: \"钱庄分号\",\n filterValue: \"bank_branch\",\n title: branch.branchName,\n meta: `${branch.cityName || \"本地\"} · ${branch.statusLabel || \"开兑\"} · ${branch.liquidityLabel || \"准备金未详\"}`,\n summary: branch.publicSummary,\n metrics: [\n [\"准备金\", branch.reserveLabel],\n [\"存款负债\", branch.depositLabel],\n [\"放款\", branch.loanLabel],\n [\"汇票\", branch.billLabel],\n [\"挤兑风险\", branch.runRiskLabel]\n ],\n tags: [branch.statusLabel, branch.licenseStatusLabel, branch.liquidityLabel, branch.runRiskLabel, ...(branch.riskTags || [])],\n cityId: branch.cityId,\n pressure: branch.cityProsperityLink,\n risk: branch.runRisk,\n turn: branch.updatedTurn,\n sortValue: branch.runRisk\n }));\n }\n for (const deposit of Array.isArray(banking.depositRows) ? banking.depositRows : []) {\n rows.push(makePageRow({\n id: `bank-deposit:${deposit.depositId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.depositRows\",\n kind: \"bank_deposit\",\n kindLabel: \"存款\",\n filterValue: \"bank_deposit\",\n title: `${deposit.depositorLabel || \"存户\"}存款`,\n meta: `${deposit.amountLabel || \"金额未详\"} · ${deposit.statusLabel || \"在账\"}`,\n summary: deposit.publicSummary,\n metrics: [\n [\"存款\", deposit.amountLabel],\n [\"状态\", deposit.statusLabel]\n ],\n tags: [deposit.depositorKind, deposit.statusLabel, \"负债\"],\n risk: deposit.status === \"frozen\" ? 66 : 26,\n turn: deposit.updatedTurn,\n sortValue: numberValue(deposit.updatedTurn)\n }));\n }\n for (const loan of Array.isArray(banking.loanRows) ? banking.loanRows : []) {\n rows.push(makePageRow({\n id: `bank-loan:${loan.loanId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.loanRows\",\n kind: \"bank_loan\",\n kindLabel: \"贷款\",\n filterValue: \"bank_loan\",\n title: `${loan.borrowerLabel || \"借户\"}贷款`,\n meta: `${loan.outstandingLabel || loan.principalLabel || \"欠额未详\"} · ${loan.statusLabel || \"在账\"}`,\n summary: loan.publicSummary,\n metrics: [\n [\"本金\", loan.principalLabel],\n [\"未清\", loan.outstandingLabel],\n [\"风险\", loan.riskLabel],\n [\"到期\", loan.dueTurn === null || loan.dueTurn === undefined ? \"未定\" : `第${loan.dueTurn}回`]\n ],\n tags: [loan.borrowerKind, loan.statusLabel, loan.riskLabel, ...(loan.riskTags || [])],\n risk: loan.riskScore,\n turn: loan.updatedTurn,\n sortValue: loan.riskScore\n }));\n }\n for (const bill of Array.isArray(banking.billRows) ? banking.billRows : []) {\n rows.push(makePageRow({\n id: `bank-bill:${bill.billId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.billRows\",\n kind: \"bank_bill\",\n kindLabel: \"汇票\",\n filterValue: \"bank_bill\",\n title: `${bill.originCityName || \"本地\"}汇票`,\n meta: `${bill.targetCityName || \"兑地\"} · ${bill.amountLabel || \"金额未详\"} · ${bill.statusLabel || \"在途\"}`,\n summary: bill.publicSummary,\n metrics: [\n [\"票面\", bill.amountLabel],\n [\"票费\", bill.feeLabel],\n [\"状态\", bill.statusLabel]\n ],\n tags: [bill.statusLabel, ...(bill.riskTags || [])],\n cityId: bill.originCityId,\n risk: bill.status === \"defaulted\" ? 86 : bill.status === \"forged_review\" ? 76 : 36,\n turn: bill.updatedTurn,\n sortValue: numberValue(bill.updatedTurn)\n }));\n }\n for (const reserve of Array.isArray(banking.reserveRows) ? banking.reserveRows : []) {\n rows.push(makePageRow({\n id: `bank-reserve:${reserve.snapshotId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.reserveRows\",\n kind: \"bank_reserve\",\n kindLabel: \"准备金\",\n filterValue: \"bank_reserve\",\n title: \"准备金留痕\",\n meta: `${reserve.reserveLabel || \"准备金未详\"} · ${reserve.liquidityLabel || \"流动性未详\"}`,\n summary: reserve.publicSummary,\n metrics: [\n [\"准备金\", reserve.reserveLabel],\n [\"存款负债\", reserve.depositLabel],\n [\"流动性\", reserve.liquidityLabel],\n [\"挤兑\", reserve.runRiskLabel]\n ],\n tags: [reserve.liquidityLabel, reserve.runRiskLabel],\n risk: reserve.runRisk,\n turn: reserve.turn,\n dateLabel: reserve.dateLabel,\n sortValue: reserve.runRisk\n }));\n }\n for (const event of Array.isArray(banking.riskEventRows) ? banking.riskEventRows : []) {\n rows.push(makePageRow({\n id: `bank-risk:${event.eventId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.riskEventRows\",\n kind: \"bank_risk_event\",\n kindLabel: \"钱庄风险\",\n filterValue: \"bank_risk_event\",\n title: event.title,\n meta: `${event.statusLabel || event.status || \"已记\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"等级\", event.severity],\n [\"状态\", event.statusLabel],\n [\"回合\", `第${event.generatedAtTurn ?? 0}回`]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38,\n turn: event.generatedAtTurn,\n dateLabel: event.dateLabel,\n sortValue: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38\n }));\n }\n for (const license of Array.isArray(banking.licenseRows) ? banking.licenseRows : []) {\n rows.push(makePageRow({\n id: `bank-license:${license.licenseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.licenseRows\",\n kind: \"bank_license\",\n kindLabel: \"钱庄牌照\",\n filterValue: \"bank_license\",\n title: \"钱庄牌照登记\",\n meta: license.statusLabel,\n summary: license.publicSummary,\n metrics: [\n [\"状态\", license.statusLabel],\n [\"来源\", license.sourcePolicyRef ? \"城市政策\" : \"清查\"]\n ],\n tags: [license.statusLabel, license.conflictOfInterestRef ? \"官商复核\" : \"\"],\n cityId: license.cityId,\n risk: license.conflictOfInterestRef ? 58 : 32,\n turn: license.updatedTurn,\n sortValue: numberValue(license.updatedTurn)\n }));\n }\n for (const action of Array.isArray(banking.actionRows) ? banking.actionRows : []) {\n rows.push(makePageRow({\n id: `bank-action:${action.actionId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.actionRows\",\n kind: \"bank_action\",\n kindLabel: \"钱庄动作\",\n filterValue: \"bank_action\",\n title: action.actionLabel,\n meta: action.accepted ? \"已受理\" : \"已拒绝\",\n summary: action.publicSummary,\n metrics: [\n [\"状态\", action.accepted ? \"已受理\" : \"已拒绝\"],\n [\"忽略字段\", Array.isArray(action.ignoredClientFields) ? action.ignoredClientFields.length : 0]\n ],\n tags: [action.actionType, action.accepted ? \"accepted\" : \"rejected\"],\n risk: action.accepted ? 36 : 70,\n turn: action.turn,\n dateLabel: action.dateLabel,\n sortValue: numberValue(action.turn)\n }));\n }\n return rows;\n}\n\nfunction buildNpcCohortRows(worldState, views) {\n const cohortView = views.npcCohortView || {};\n const rows = [];\n for (const cohort of Array.isArray(cohortView.cohortRows) ? cohortView.cohortRows : []) {\n rows.push(makePageRow({\n id: `npc-cohort:${cohort.cohortId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.cohortRows\",\n kind: \"npc_cohort\",\n filterValue: \"npc_cohort\",\n title: `${cohort.homeCityName || \"\"}${cohort.displayName || \"商帮工群\"}`,\n meta: `${cohort.statusLabel || \"已登记\"} · ${cohort.cohortType || \"cohort\"}`,\n summary: cohort.publicSummary,\n metrics: [\n [\"人数\", cohort.population],\n [\"满意\", cohort.satisfaction],\n [\"影响\", cohort.influence],\n [\"压力\", cohort.pressureScore]\n ],\n tags: [cohort.statusLabel, ...(cohort.goodsLabels || []), ...(cohort.tags || [])],\n cityId: cohort.homeCityId,\n pressure: cohort.pressureScore,\n risk: cohort.pressureScore,\n turn: cohort.lastUpdatedTurn,\n sortValue: cohort.pressureScore\n }));\n }\n for (const actor of Array.isArray(cohortView.semiNamedRows) ? cohortView.semiNamedRows : []) {\n rows.push(makePageRow({\n id: `semi-named:${actor.actorId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.semiNamedRows\",\n kind: \"semi_named_actor\",\n filterValue: \"semi_named_actor\",\n title: actor.displayName,\n meta: `${actor.roleLabel || \"代表\"} · ${actor.cityName || \"本地\"}`,\n summary: actor.publicSummary,\n metrics: [\n [\"压力\", actor.pressureScore],\n [\"影响\", actor.influence],\n [\"信任代理\", actor.trustProxy],\n [\"升格\", actor.upgradeEligible ? \"可升格\" : \"观察\"]\n ],\n tags: actor.tags || [],\n cityId: actor.cityId,\n pressure: actor.pressureScore,\n risk: actor.upgradeEligible ? 78 : actor.pressureScore,\n turn: actor.lastUpdatedTurn,\n sortValue: actor.pressureScore\n }));\n }\n for (const promotion of Array.isArray(cohortView.namedPromotionRows) ? cohortView.namedPromotionRows : []) {\n rows.push(makePageRow({\n id: `named-promotion:${promotion.promotionId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.namedPromotionRows\",\n kind: \"named_promotion\",\n filterValue: \"named_promotion\",\n title: promotion.displayName,\n meta: `${promotion.status || \"promoted\"} · ${promotion.cityName || \"\"}`,\n summary: promotion.publicSummary,\n metrics: [\n [\"NPC\", promotion.npcId],\n [\"回合\", promotion.promotedTurn]\n ],\n tags: promotion.tags || [],\n pressure: 80,\n risk: 66,\n turn: promotion.promotedTurn,\n sortValue: promotion.promotedTurn\n }));\n }\n for (const reaction of Array.isArray(cohortView.marketReactionRows) ? cohortView.marketReactionRows : []) {\n rows.push(makePageRow({\n id: `market-reaction:${reaction.reactionId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.marketReactionRows\",\n kind: \"market_reaction\",\n filterValue: \"market_reaction\",\n title: reaction.reasonLabel,\n meta: `${reaction.cityName || \"本地\"} · 压力${reaction.pressureDelta ?? 0}`,\n summary: reaction.publicSummary,\n metrics: [\n [\"压力变动\", reaction.pressureDelta],\n [\"需求\", reaction.demandDelta],\n [\"供给\", reaction.supplyDelta]\n ],\n tags: [...(reaction.goodsLabels || []), ...(reaction.marketCategories || [])],\n cityId: reaction.cityId,\n pressure: Math.abs(numberValue(reaction.pressureDelta)),\n risk: Math.abs(numberValue(reaction.pressureDelta)) * 10,\n turn: reaction.createdTurn,\n sortValue: Math.abs(numberValue(reaction.pressureDelta))\n }));\n }\n for (const shadow of Array.isArray(cohortView.shadowActorRows) ? cohortView.shadowActorRows : []) {\n rows.push(makePageRow({\n id: `market-shadow:${shadow.shadowId}`,\n tabId: \"npc-cohorts\",\n sourceView: \"npcCohortView.shadowActorRows\",\n kind: \"market_shadow\",\n filterValue: \"market_shadow\",\n title: shadow.label,\n meta: `${shadow.cityName || \"本地\"} · ${shadow.estimatedScale || \"shadow\"}`,\n summary: shadow.publicSummary,\n metrics: [[\"压力\", shadow.pressureDelta]],\n tags: shadow.marketCategories || [],\n cityId: shadow.cityId,\n pressure: Math.abs(numberValue(shadow.pressureDelta)),\n risk: Math.abs(numberValue(shadow.pressureDelta)) * 10,\n turn: shadow.lastUpdatedTurn,\n sortValue: Math.abs(numberValue(shadow.pressureDelta))\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"player-enterprises\") return buildPlayerEnterpriseRows(worldState, views);\n if (tabId === \"banking\") return buildBankingRows(worldState, views);\n if (tabId === \"npc-cohorts\") return buildNpcCohortRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState),\n playerEnterpriseView: views.playerEnterpriseView || buildPlayerEnterpriseView(worldState),\n bankingView: views.bankingView || buildBankingView(worldState),\n npcCohortView: views.npcCohortView || buildNpcCohortView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "195430aabbfcccf6248ccc2ebffe1b4afdcdad46": {"t": 1785753936.1697438, "n": 58566, "h": "f7f68126d8007654", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildPlayerEnterpriseView } = require(\"./industryLedger\");\nconst { buildBankingView } = require(\"./bankingLedger\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"player-enterprises\",\n \"banking\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|bankingLedger|conflictOfInterestLedger|productionRecipe|laborCohort|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n enterprise: \"产业\",\n production_cycle: \"产程\",\n labor_row: \"劳力\",\n enterprise_stock: \"库存\",\n industry_event: \"产业事件\",\n banking_market: \"钱庄市面\",\n bank_house: \"钱庄总号\",\n bank_branch: \"钱庄分号\",\n bank_deposit: \"存款\",\n bank_loan: \"贷款\",\n bank_bill: \"汇票\",\n bank_reserve: \"准备金\",\n bank_risk_event: \"钱庄风险\",\n bank_license: \"钱庄牌照\",\n bank_action: \"钱庄动作\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"player-enterprises\": \"enterprise\",\n banking: \"bank_branch\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"player-enterprises\": \"risk\",\n banking: \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildPlayerEnterpriseRows(worldState, views) {\n const enterprises = views.playerEnterpriseView || {};\n const rows = [];\n for (const enterprise of Array.isArray(enterprises.enterpriseRows) ? enterprises.enterpriseRows : []) {\n rows.push(makePageRow({\n id: `enterprise:${enterprise.enterpriseId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.enterpriseRows\",\n kind: \"enterprise\",\n kindLabel: \"产业\",\n filterValue: \"enterprise\",\n title: enterprise.name,\n meta: `${enterprise.enterpriseTypeLabel || \"产业\"} · ${enterprise.statusLabel || \"已登记\"} · ${enterprise.cityName || \"本地\"}`,\n summary: enterprise.publicSummary,\n extra: compactList([\n enterprise.accountantLabel,\n enterprise.permitStatusLabel,\n ...(enterprise.channelHints || [])\n ], \"\"),\n metrics: [\n [\"品相\", enterprise.qualityScore],\n [\"维护\", `${numberValue(enterprise.maintenanceReserveCopper, 0)}文`],\n [\"劳力\", enterprise.laborSummary?.statusLabel || \"未雇工\"],\n [\"状态\", enterprise.statusLabel]\n ],\n tags: [enterprise.enterpriseTypeLabel, enterprise.industryLabel, ...(enterprise.riskTags || []), ...(enterprise.conflictFlags || [])],\n cityId: enterprise.cityId,\n cityName: enterprise.cityName,\n pressure: Math.max(numberValue(enterprise.maintenanceRequiredCopper, 0) - numberValue(enterprise.maintenanceReserveCopper, 0), 0) / 100,\n risk: (enterprise.conflictFlags || []).length ? 82 : (enterprise.status === \"halted\" || enterprise.status === \"cancelled\" ? 74 : 42),\n turn: enterprise.updatedTurn,\n sortValue: numberValue(enterprise.updatedTurn)\n }));\n }\n for (const cycle of Array.isArray(enterprises.productionRows) ? enterprises.productionRows : []) {\n rows.push(makePageRow({\n id: `production-cycle:${cycle.cycleId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.productionRows\",\n kind: \"production_cycle\",\n kindLabel: \"产程\",\n filterValue: \"production_cycle\",\n title: `${cycle.recipeLabel}产程`,\n meta: `${cycle.statusLabel || \"营作\"} · 到期第${cycle.dueTurn ?? \"?\"}回`,\n summary: cycle.publicSummary,\n extra: compactList((cycle.outputGoods || []).map((goods) => `${goods.displayName}${goods.quantity}${goods.unitLabel || \"\"}`), \"\"),\n metrics: [\n [\"劳力\", `${numberValue(cycle.assignedLabor, 0)}/${numberValue(cycle.requiredLabor, 0)}`],\n [\"维护\", `${numberValue(cycle.maintenanceCostCopper, 0)}文`],\n [\"品相\", cycle.qualityScore],\n [\"到期\", cycle.dueTurn === null || cycle.dueTurn === undefined ? \"未定\" : `第${cycle.dueTurn}回`]\n ],\n tags: [cycle.recipeLabel, cycle.statusLabel, ...(cycle.riskTags || [])],\n risk: cycle.status === \"failed\" ? 86 : cycle.status === \"in_progress\" ? 54 : 32,\n turn: cycle.resolvedTurn || cycle.startedTurn,\n sortValue: cycle.status === \"in_progress\" ? 80 : numberValue(cycle.resolvedTurn || cycle.startedTurn)\n }));\n }\n for (const labor of Array.isArray(enterprises.laborRows) ? enterprises.laborRows : []) {\n rows.push(makePageRow({\n id: `enterprise-labor:${labor.laborId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.laborRows\",\n kind: \"labor_row\",\n kindLabel: \"劳力\",\n filterValue: \"labor_row\",\n title: `${labor.skillLabel} ${labor.workerCount}人`,\n meta: labor.statusLabel,\n summary: labor.publicSummary,\n metrics: [\n [\"人数\", labor.workerCount],\n [\"士气\", labor.morale],\n [\"工钱\", `${numberValue(labor.wageCopperPerWorker, 0)}文/人`]\n ],\n tags: [labor.skillLabel, labor.statusLabel],\n risk: numberValue(labor.morale, 0) < 45 ? 72 : 30,\n turn: labor.updatedTurn,\n sortValue: numberValue(labor.updatedTurn)\n }));\n }\n for (const stock of Array.isArray(enterprises.stockRows) ? enterprises.stockRows : []) {\n rows.push(makePageRow({\n id: `enterprise-stock:${stock.stockId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.stockRows\",\n kind: \"enterprise_stock\",\n kindLabel: \"库存\",\n filterValue: \"enterprise_stock\",\n title: `${stock.displayName}${stock.statusLabel}`,\n meta: `${stock.quantity}${stock.unitLabel || \"\"} · ${stock.permitStatusLabel || \"普通货物\"}`,\n summary: stock.publicSummary,\n extra: stock.sourceLabel,\n metrics: [\n [\"数量\", `${numberValue(stock.quantity, 0)}${stock.unitLabel || \"\"}`],\n [\"状态\", stock.statusLabel],\n [\"法度\", stock.legalStatus]\n ],\n tags: [stock.displayName, stock.statusLabel, stock.permitStatusLabel],\n risk: stock.permitStatusLabel === \"需牌照核验\" ? 76 : 28,\n sortValue: numberValue(stock.quantity)\n }));\n }\n for (const event of Array.isArray(enterprises.eventRows) ? enterprises.eventRows : []) {\n rows.push(makePageRow({\n id: `industry-event:${event.eventId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.eventRows\",\n kind: \"industry_event\",\n kindLabel: \"产业事件\",\n filterValue: \"industry_event\",\n title: event.title,\n meta: `${event.dateLabel || \"\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"回数\", `第${event.turn ?? 0}回`],\n [\"等级\", event.severity]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"warning\" ? 78 : 36,\n turn: event.turn,\n dateLabel: event.dateLabel,\n sortValue: numberValue(event.turn)\n }));\n }\n return rows;\n}\n\nfunction buildBankingRows(worldState, views) {\n const banking = views.bankingView || {};\n const rows = [];\n for (const market of Array.isArray(banking.marketRows) ? banking.marketRows : []) {\n rows.push(makePageRow({\n id: `banking-market:${market.cityId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.marketRows\",\n kind: \"banking_market\",\n kindLabel: \"钱庄市面\",\n filterValue: \"banking_market\",\n title: `${market.cityName}钱庄市面`,\n meta: `存款${market.depositPotentialLabel || \"\"} · 放款${market.loanDemandLabel || \"\"}`,\n summary: market.publicSummary,\n metrics: [\n [\"存款潜势\", market.depositPotentialLabel],\n [\"放款需求\", market.loanDemandLabel],\n [\"汇票需求\", market.billDemandLabel],\n [\"坏账压力\", market.defaultStressLabel]\n ],\n tags: [\"钱庄市面\", market.defaultStressLabel],\n cityId: market.cityId,\n pressure: market.marketPressure,\n risk: market.defaultStressScore,\n sortValue: market.defaultStressScore\n }));\n }\n for (const house of Array.isArray(banking.houseRows) ? banking.houseRows : []) {\n rows.push(makePageRow({\n id: `bank-house:${house.houseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.houseRows\",\n kind: \"bank_house\",\n kindLabel: \"钱庄总号\",\n filterValue: \"bank_house\",\n title: house.name,\n meta: `${house.statusLabel || \"营运\"} · ${house.licenseStatusLabel || \"牌照待核\"}`,\n summary: house.publicSummary,\n metrics: [\n [\"声誉\", house.reputation],\n [\"清查风险\", house.auditRisk],\n [\"分号\", Array.isArray(house.branchIds) ? house.branchIds.length : 0]\n ],\n tags: [house.statusLabel, house.licenseStatusLabel, ...(house.riskTags || [])],\n risk: house.auditRisk,\n turn: house.updatedTurn,\n sortValue: house.auditRisk\n }));\n }\n for (const branch of Array.isArray(banking.branchRows) ? banking.branchRows : []) {\n rows.push(makePageRow({\n id: `bank-branch:${branch.branchId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.branchRows\",\n kind: \"bank_branch\",\n kindLabel: \"钱庄分号\",\n filterValue: \"bank_branch\",\n title: branch.branchName,\n meta: `${branch.cityName || \"本地\"} · ${branch.statusLabel || \"开兑\"} · ${branch.liquidityLabel || \"准备金未详\"}`,\n summary: branch.publicSummary,\n metrics: [\n [\"准备金\", branch.reserveLabel],\n [\"存款负债\", branch.depositLabel],\n [\"放款\", branch.loanLabel],\n [\"汇票\", branch.billLabel],\n [\"挤兑风险\", branch.runRiskLabel]\n ],\n tags: [branch.statusLabel, branch.licenseStatusLabel, branch.liquidityLabel, branch.runRiskLabel, ...(branch.riskTags || [])],\n cityId: branch.cityId,\n pressure: branch.cityProsperityLink,\n risk: branch.runRisk,\n turn: branch.updatedTurn,\n sortValue: branch.runRisk\n }));\n }\n for (const deposit of Array.isArray(banking.depositRows) ? banking.depositRows : []) {\n rows.push(makePageRow({\n id: `bank-deposit:${deposit.depositId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.depositRows\",\n kind: \"bank_deposit\",\n kindLabel: \"存款\",\n filterValue: \"bank_deposit\",\n title: `${deposit.depositorLabel || \"存户\"}存款`,\n meta: `${deposit.amountLabel || \"金额未详\"} · ${deposit.statusLabel || \"在账\"}`,\n summary: deposit.publicSummary,\n metrics: [\n [\"存款\", deposit.amountLabel],\n [\"状态\", deposit.statusLabel]\n ],\n tags: [deposit.depositorKind, deposit.statusLabel, \"负债\"],\n risk: deposit.status === \"frozen\" ? 66 : 26,\n turn: deposit.updatedTurn,\n sortValue: numberValue(deposit.updatedTurn)\n }));\n }\n for (const loan of Array.isArray(banking.loanRows) ? banking.loanRows : []) {\n rows.push(makePageRow({\n id: `bank-loan:${loan.loanId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.loanRows\",\n kind: \"bank_loan\",\n kindLabel: \"贷款\",\n filterValue: \"bank_loan\",\n title: `${loan.borrowerLabel || \"借户\"}贷款`,\n meta: `${loan.outstandingLabel || loan.principalLabel || \"欠额未详\"} · ${loan.statusLabel || \"在账\"}`,\n summary: loan.publicSummary,\n metrics: [\n [\"本金\", loan.principalLabel],\n [\"未清\", loan.outstandingLabel],\n [\"风险\", loan.riskLabel],\n [\"到期\", loan.dueTurn === null || loan.dueTurn === undefined ? \"未定\" : `第${loan.dueTurn}回`]\n ],\n tags: [loan.borrowerKind, loan.statusLabel, loan.riskLabel, ...(loan.riskTags || [])],\n risk: loan.riskScore,\n turn: loan.updatedTurn,\n sortValue: loan.riskScore\n }));\n }\n for (const bill of Array.isArray(banking.billRows) ? banking.billRows : []) {\n rows.push(makePageRow({\n id: `bank-bill:${bill.billId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.billRows\",\n kind: \"bank_bill\",\n kindLabel: \"汇票\",\n filterValue: \"bank_bill\",\n title: `${bill.originCityName || \"本地\"}汇票`,\n meta: `${bill.targetCityName || \"兑地\"} · ${bill.amountLabel || \"金额未详\"} · ${bill.statusLabel || \"在途\"}`,\n summary: bill.publicSummary,\n metrics: [\n [\"票面\", bill.amountLabel],\n [\"票费\", bill.feeLabel],\n [\"状态\", bill.statusLabel]\n ],\n tags: [bill.statusLabel, ...(bill.riskTags || [])],\n cityId: bill.originCityId,\n risk: bill.status === \"defaulted\" ? 86 : bill.status === \"forged_review\" ? 76 : 36,\n turn: bill.updatedTurn,\n sortValue: numberValue(bill.updatedTurn)\n }));\n }\n for (const reserve of Array.isArray(banking.reserveRows) ? banking.reserveRows : []) {\n rows.push(makePageRow({\n id: `bank-reserve:${reserve.snapshotId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.reserveRows\",\n kind: \"bank_reserve\",\n kindLabel: \"准备金\",\n filterValue: \"bank_reserve\",\n title: \"准备金留痕\",\n meta: `${reserve.reserveLabel || \"准备金未详\"} · ${reserve.liquidityLabel || \"流动性未详\"}`,\n summary: reserve.publicSummary,\n metrics: [\n [\"准备金\", reserve.reserveLabel],\n [\"存款负债\", reserve.depositLabel],\n [\"流动性\", reserve.liquidityLabel],\n [\"挤兑\", reserve.runRiskLabel]\n ],\n tags: [reserve.liquidityLabel, reserve.runRiskLabel],\n risk: reserve.runRisk,\n turn: reserve.turn,\n dateLabel: reserve.dateLabel,\n sortValue: reserve.runRisk\n }));\n }\n for (const event of Array.isArray(banking.riskEventRows) ? banking.riskEventRows : []) {\n rows.push(makePageRow({\n id: `bank-risk:${event.eventId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.riskEventRows\",\n kind: \"bank_risk_event\",\n kindLabel: \"钱庄风险\",\n filterValue: \"bank_risk_event\",\n title: event.title,\n meta: `${event.statusLabel || event.status || \"已记\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"等级\", event.severity],\n [\"状态\", event.statusLabel],\n [\"回合\", `第${event.generatedAtTurn ?? 0}回`]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38,\n turn: event.generatedAtTurn,\n dateLabel: event.dateLabel,\n sortValue: event.severity === \"severe\" ? 92 : event.severity === \"warning\" ? 76 : 38\n }));\n }\n for (const license of Array.isArray(banking.licenseRows) ? banking.licenseRows : []) {\n rows.push(makePageRow({\n id: `bank-license:${license.licenseId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.licenseRows\",\n kind: \"bank_license\",\n kindLabel: \"钱庄牌照\",\n filterValue: \"bank_license\",\n title: \"钱庄牌照登记\",\n meta: license.statusLabel,\n summary: license.publicSummary,\n metrics: [\n [\"状态\", license.statusLabel],\n [\"来源\", license.sourcePolicyRef ? \"城市政策\" : \"清查\"]\n ],\n tags: [license.statusLabel, license.conflictOfInterestRef ? \"官商复核\" : \"\"],\n cityId: license.cityId,\n risk: license.conflictOfInterestRef ? 58 : 32,\n turn: license.updatedTurn,\n sortValue: numberValue(license.updatedTurn)\n }));\n }\n for (const action of Array.isArray(banking.actionRows) ? banking.actionRows : []) {\n rows.push(makePageRow({\n id: `bank-action:${action.actionId}`,\n tabId: \"banking\",\n sourceView: \"bankingView.actionRows\",\n kind: \"bank_action\",\n kindLabel: \"钱庄动作\",\n filterValue: \"bank_action\",\n title: action.actionLabel,\n meta: action.accepted ? \"已受理\" : \"已拒绝\",\n summary: action.publicSummary,\n metrics: [\n [\"状态\", action.accepted ? \"已受理\" : \"已拒绝\"],\n [\"忽略字段\", Array.isArray(action.ignoredClientFields) ? action.ignoredClientFields.length : 0]\n ],\n tags: [action.actionType, action.accepted ? \"accepted\" : \"rejected\"],\n risk: action.accepted ? 36 : 70,\n turn: action.turn,\n dateLabel: action.dateLabel,\n sortValue: numberValue(action.turn)\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"player-enterprises\") return buildPlayerEnterpriseRows(worldState, views);\n if (tabId === \"banking\") return buildBankingRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState),\n playerEnterpriseView: views.playerEnterpriseView || buildPlayerEnterpriseView(worldState),\n bankingView: views.bankingView || buildBankingView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "04c0eef732ac97a0cce44bd959910f6712fd0541": {"t": 1785753939.8389401, "n": 49248, "h": "4f11730804963634", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildPlayerEnterpriseView } = require(\"./industryLedger\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"player-enterprises\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|conflictOfInterestLedger|productionRecipe|laborCohort|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n enterprise: \"产业\",\n production_cycle: \"产程\",\n labor_row: \"劳力\",\n enterprise_stock: \"库存\",\n industry_event: \"产业事件\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"player-enterprises\": \"enterprise\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"player-enterprises\": \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildPlayerEnterpriseRows(worldState, views) {\n const enterprises = views.playerEnterpriseView || {};\n const rows = [];\n for (const enterprise of Array.isArray(enterprises.enterpriseRows) ? enterprises.enterpriseRows : []) {\n rows.push(makePageRow({\n id: `enterprise:${enterprise.enterpriseId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.enterpriseRows\",\n kind: \"enterprise\",\n kindLabel: \"产业\",\n filterValue: \"enterprise\",\n title: enterprise.name,\n meta: `${enterprise.enterpriseTypeLabel || \"产业\"} · ${enterprise.statusLabel || \"已登记\"} · ${enterprise.cityName || \"本地\"}`,\n summary: enterprise.publicSummary,\n extra: compactList([\n enterprise.accountantLabel,\n enterprise.permitStatusLabel,\n ...(enterprise.channelHints || [])\n ], \"\"),\n metrics: [\n [\"品相\", enterprise.qualityScore],\n [\"维护\", `${numberValue(enterprise.maintenanceReserveCopper, 0)}文`],\n [\"劳力\", enterprise.laborSummary?.statusLabel || \"未雇工\"],\n [\"状态\", enterprise.statusLabel]\n ],\n tags: [enterprise.enterpriseTypeLabel, enterprise.industryLabel, ...(enterprise.riskTags || []), ...(enterprise.conflictFlags || [])],\n cityId: enterprise.cityId,\n cityName: enterprise.cityName,\n pressure: Math.max(numberValue(enterprise.maintenanceRequiredCopper, 0) - numberValue(enterprise.maintenanceReserveCopper, 0), 0) / 100,\n risk: (enterprise.conflictFlags || []).length ? 82 : (enterprise.status === \"halted\" || enterprise.status === \"cancelled\" ? 74 : 42),\n turn: enterprise.updatedTurn,\n sortValue: numberValue(enterprise.updatedTurn)\n }));\n }\n for (const cycle of Array.isArray(enterprises.productionRows) ? enterprises.productionRows : []) {\n rows.push(makePageRow({\n id: `production-cycle:${cycle.cycleId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.productionRows\",\n kind: \"production_cycle\",\n kindLabel: \"产程\",\n filterValue: \"production_cycle\",\n title: `${cycle.recipeLabel}产程`,\n meta: `${cycle.statusLabel || \"营作\"} · 到期第${cycle.dueTurn ?? \"?\"}回`,\n summary: cycle.publicSummary,\n extra: compactList((cycle.outputGoods || []).map((goods) => `${goods.displayName}${goods.quantity}${goods.unitLabel || \"\"}`), \"\"),\n metrics: [\n [\"劳力\", `${numberValue(cycle.assignedLabor, 0)}/${numberValue(cycle.requiredLabor, 0)}`],\n [\"维护\", `${numberValue(cycle.maintenanceCostCopper, 0)}文`],\n [\"品相\", cycle.qualityScore],\n [\"到期\", cycle.dueTurn === null || cycle.dueTurn === undefined ? \"未定\" : `第${cycle.dueTurn}回`]\n ],\n tags: [cycle.recipeLabel, cycle.statusLabel, ...(cycle.riskTags || [])],\n risk: cycle.status === \"failed\" ? 86 : cycle.status === \"in_progress\" ? 54 : 32,\n turn: cycle.resolvedTurn || cycle.startedTurn,\n sortValue: cycle.status === \"in_progress\" ? 80 : numberValue(cycle.resolvedTurn || cycle.startedTurn)\n }));\n }\n for (const labor of Array.isArray(enterprises.laborRows) ? enterprises.laborRows : []) {\n rows.push(makePageRow({\n id: `enterprise-labor:${labor.laborId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.laborRows\",\n kind: \"labor_row\",\n kindLabel: \"劳力\",\n filterValue: \"labor_row\",\n title: `${labor.skillLabel} ${labor.workerCount}人`,\n meta: labor.statusLabel,\n summary: labor.publicSummary,\n metrics: [\n [\"人数\", labor.workerCount],\n [\"士气\", labor.morale],\n [\"工钱\", `${numberValue(labor.wageCopperPerWorker, 0)}文/人`]\n ],\n tags: [labor.skillLabel, labor.statusLabel],\n risk: numberValue(labor.morale, 0) < 45 ? 72 : 30,\n turn: labor.updatedTurn,\n sortValue: numberValue(labor.updatedTurn)\n }));\n }\n for (const stock of Array.isArray(enterprises.stockRows) ? enterprises.stockRows : []) {\n rows.push(makePageRow({\n id: `enterprise-stock:${stock.stockId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.stockRows\",\n kind: \"enterprise_stock\",\n kindLabel: \"库存\",\n filterValue: \"enterprise_stock\",\n title: `${stock.displayName}${stock.statusLabel}`,\n meta: `${stock.quantity}${stock.unitLabel || \"\"} · ${stock.permitStatusLabel || \"普通货物\"}`,\n summary: stock.publicSummary,\n extra: stock.sourceLabel,\n metrics: [\n [\"数量\", `${numberValue(stock.quantity, 0)}${stock.unitLabel || \"\"}`],\n [\"状态\", stock.statusLabel],\n [\"法度\", stock.legalStatus]\n ],\n tags: [stock.displayName, stock.statusLabel, stock.permitStatusLabel],\n risk: stock.permitStatusLabel === \"需牌照核验\" ? 76 : 28,\n sortValue: numberValue(stock.quantity)\n }));\n }\n for (const event of Array.isArray(enterprises.eventRows) ? enterprises.eventRows : []) {\n rows.push(makePageRow({\n id: `industry-event:${event.eventId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.eventRows\",\n kind: \"industry_event\",\n kindLabel: \"产业事件\",\n filterValue: \"industry_event\",\n title: event.title,\n meta: `${event.dateLabel || \"\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"回数\", `第${event.turn ?? 0}回`],\n [\"等级\", event.severity]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"warning\" ? 78 : 36,\n turn: event.turn,\n dateLabel: event.dateLabel,\n sortValue: numberValue(event.turn)\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"player-enterprises\") return buildPlayerEnterpriseRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState),\n playerEnterpriseView: views.playerEnterpriseView || buildPlayerEnterpriseView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "4884269f8f49954c18cc37cdae2345145bc442f1": {"t": 1785753942.1424706, "n": 49223, "h": "3a0597c3bb5f8135", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildPlayerEnterpriseView } = require(\"./industryLedger\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"player-enterprises\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|productionRecipe|laborCohort|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n enterprise: \"产业\",\n production_cycle: \"产程\",\n labor_row: \"劳力\",\n enterprise_stock: \"库存\",\n industry_event: \"产业事件\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"player-enterprises\": \"enterprise\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"player-enterprises\": \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildPlayerEnterpriseRows(worldState, views) {\n const enterprises = views.playerEnterpriseView || {};\n const rows = [];\n for (const enterprise of Array.isArray(enterprises.enterpriseRows) ? enterprises.enterpriseRows : []) {\n rows.push(makePageRow({\n id: `enterprise:${enterprise.enterpriseId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.enterpriseRows\",\n kind: \"enterprise\",\n kindLabel: \"产业\",\n filterValue: \"enterprise\",\n title: enterprise.name,\n meta: `${enterprise.enterpriseTypeLabel || \"产业\"} · ${enterprise.statusLabel || \"已登记\"} · ${enterprise.cityName || \"本地\"}`,\n summary: enterprise.publicSummary,\n extra: compactList([\n enterprise.accountantLabel,\n enterprise.permitStatusLabel,\n ...(enterprise.channelHints || [])\n ], \"\"),\n metrics: [\n [\"品相\", enterprise.qualityScore],\n [\"维护\", `${numberValue(enterprise.maintenanceReserveCopper, 0)}文`],\n [\"劳力\", enterprise.laborSummary?.statusLabel || \"未雇工\"],\n [\"状态\", enterprise.statusLabel]\n ],\n tags: [enterprise.enterpriseTypeLabel, enterprise.industryLabel, ...(enterprise.riskTags || []), ...(enterprise.conflictFlags || [])],\n cityId: enterprise.cityId,\n cityName: enterprise.cityName,\n pressure: Math.max(numberValue(enterprise.maintenanceRequiredCopper, 0) - numberValue(enterprise.maintenanceReserveCopper, 0), 0) / 100,\n risk: (enterprise.conflictFlags || []).length ? 82 : (enterprise.status === \"halted\" || enterprise.status === \"cancelled\" ? 74 : 42),\n turn: enterprise.updatedTurn,\n sortValue: numberValue(enterprise.updatedTurn)\n }));\n }\n for (const cycle of Array.isArray(enterprises.productionRows) ? enterprises.productionRows : []) {\n rows.push(makePageRow({\n id: `production-cycle:${cycle.cycleId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.productionRows\",\n kind: \"production_cycle\",\n kindLabel: \"产程\",\n filterValue: \"production_cycle\",\n title: `${cycle.recipeLabel}产程`,\n meta: `${cycle.statusLabel || \"营作\"} · 到期第${cycle.dueTurn ?? \"?\"}回`,\n summary: cycle.publicSummary,\n extra: compactList((cycle.outputGoods || []).map((goods) => `${goods.displayName}${goods.quantity}${goods.unitLabel || \"\"}`), \"\"),\n metrics: [\n [\"劳力\", `${numberValue(cycle.assignedLabor, 0)}/${numberValue(cycle.requiredLabor, 0)}`],\n [\"维护\", `${numberValue(cycle.maintenanceCostCopper, 0)}文`],\n [\"品相\", cycle.qualityScore],\n [\"到期\", cycle.dueTurn === null || cycle.dueTurn === undefined ? \"未定\" : `第${cycle.dueTurn}回`]\n ],\n tags: [cycle.recipeLabel, cycle.statusLabel, ...(cycle.riskTags || [])],\n risk: cycle.status === \"failed\" ? 86 : cycle.status === \"in_progress\" ? 54 : 32,\n turn: cycle.resolvedTurn || cycle.startedTurn,\n sortValue: cycle.status === \"in_progress\" ? 80 : numberValue(cycle.resolvedTurn || cycle.startedTurn)\n }));\n }\n for (const labor of Array.isArray(enterprises.laborRows) ? enterprises.laborRows : []) {\n rows.push(makePageRow({\n id: `enterprise-labor:${labor.laborId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.laborRows\",\n kind: \"labor_row\",\n kindLabel: \"劳力\",\n filterValue: \"labor_row\",\n title: `${labor.skillLabel} ${labor.workerCount}人`,\n meta: labor.statusLabel,\n summary: labor.publicSummary,\n metrics: [\n [\"人数\", labor.workerCount],\n [\"士气\", labor.morale],\n [\"工钱\", `${numberValue(labor.wageCopperPerWorker, 0)}文/人`]\n ],\n tags: [labor.skillLabel, labor.statusLabel],\n risk: numberValue(labor.morale, 0) < 45 ? 72 : 30,\n turn: labor.updatedTurn,\n sortValue: numberValue(labor.updatedTurn)\n }));\n }\n for (const stock of Array.isArray(enterprises.stockRows) ? enterprises.stockRows : []) {\n rows.push(makePageRow({\n id: `enterprise-stock:${stock.stockId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.stockRows\",\n kind: \"enterprise_stock\",\n kindLabel: \"库存\",\n filterValue: \"enterprise_stock\",\n title: `${stock.displayName}${stock.statusLabel}`,\n meta: `${stock.quantity}${stock.unitLabel || \"\"} · ${stock.permitStatusLabel || \"普通货物\"}`,\n summary: stock.publicSummary,\n extra: stock.sourceLabel,\n metrics: [\n [\"数量\", `${numberValue(stock.quantity, 0)}${stock.unitLabel || \"\"}`],\n [\"状态\", stock.statusLabel],\n [\"法度\", stock.legalStatus]\n ],\n tags: [stock.displayName, stock.statusLabel, stock.permitStatusLabel],\n risk: stock.permitStatusLabel === \"需牌照核验\" ? 76 : 28,\n sortValue: numberValue(stock.quantity)\n }));\n }\n for (const event of Array.isArray(enterprises.eventRows) ? enterprises.eventRows : []) {\n rows.push(makePageRow({\n id: `industry-event:${event.eventId}`,\n tabId: \"player-enterprises\",\n sourceView: \"playerEnterpriseView.eventRows\",\n kind: \"industry_event\",\n kindLabel: \"产业事件\",\n filterValue: \"industry_event\",\n title: event.title,\n meta: `${event.dateLabel || \"\"} · ${event.severity || \"info\"}`,\n summary: event.publicSummary,\n metrics: [\n [\"回数\", `第${event.turn ?? 0}回`],\n [\"等级\", event.severity]\n ],\n tags: event.riskTags || [],\n risk: event.severity === \"warning\" ? 78 : 36,\n turn: event.turn,\n dateLabel: event.dateLabel,\n sortValue: numberValue(event.turn)\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"player-enterprises\") return buildPlayerEnterpriseRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState),\n playerEnterpriseView: views.playerEnterpriseView || buildPlayerEnterpriseView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "87fddffac0a32c4ea32410e72ebce508045844dc": {"t": 1785753944.70925, "n": 43178, "h": "d20732ab548f8c60", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildTradeRouteView } = require(\"./tradeRoutes\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"trade-routes\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|tradeRouteLedger|caravanLedger|economicContractLedger|contractLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n trade_route: \"商路\",\n caravan: \"商队\",\n economic_contract: \"经济契约\",\n caravan_settlement: \"交割复盘\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"trade-routes\": \"trade_route\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"trade-routes\": \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildTradeRouteRows(worldState, views) {\n const tradeRoutes = views.tradeRouteView || {};\n const rows = [];\n for (const route of Array.isArray(tradeRoutes.routeRows) ? tradeRoutes.routeRows : []) {\n rows.push(makePageRow({\n id: `trade-route:${route.routeId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routeRows\",\n kind: \"trade_route\",\n kindLabel: \"商路\",\n filterValue: \"trade_route\",\n title: `${route.routeName}商路`,\n meta: `${route.fromCityName || \"起地\"}至${route.toCityName || \"到地\"} · ${route.riskLabel || \"可通\"}`,\n summary: route.publicSummary,\n extra: route.seasonalRisk,\n metrics: [\n [\"风险\", route.riskScore],\n [\"状态\", route.statusLabel],\n [\"里程\", route.distanceLabel]\n ],\n tags: [route.routeType, route.riskLabel, ...(route.strategicTags || [])],\n routeId: route.routeId,\n risk: route.riskScore,\n sortValue: route.riskScore\n }));\n }\n for (const plan of Array.isArray(tradeRoutes.routePlans) ? tradeRoutes.routePlans : []) {\n rows.push(makePageRow({\n id: `trade-route-plan:${plan.planId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.routePlans\",\n kind: \"trade_route\",\n kindLabel: \"探价\",\n filterValue: \"trade_route\",\n title: `${plan.originCityName}至${plan.destinationCityName}探价`,\n meta: `${plan.routeName || \"商路\"} · ${plan.riskLabel || \"风险未详\"}`,\n summary: plan.publicSummary,\n extra: compactList((plan.goodsEstimates || []).map((goods) => goods.displayName), \"未定货批\"),\n metrics: [\n [\"预计盈亏\", `${numberValue(plan.expectedProfitCopper, 0)}文`],\n [\"货重\", plan.cargoWeight],\n [\"税卡\", `${numberValue(plan.taxBps, 0)}bps`]\n ],\n tags: [plan.riskLabel, ...(plan.riskTags || []), ...(plan.conflictFlags || [])],\n routeId: plan.routeId,\n risk: plan.riskScore,\n turn: plan.turn,\n dateLabel: plan.dateLabel,\n sortValue: Math.max(numberValue(plan.riskScore), Math.abs(numberValue(plan.expectedProfitCopper, 0)) / 1000)\n }));\n }\n for (const caravan of Array.isArray(tradeRoutes.caravanRows) ? tradeRoutes.caravanRows : []) {\n rows.push(makePageRow({\n id: `caravan:${caravan.caravanId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.caravanRows\",\n kind: \"caravan\",\n kindLabel: \"商队\",\n filterValue: \"caravan\",\n title: `${caravan.routeName}商队`,\n meta: `${caravan.originCityName || \"起地\"}至${caravan.destinationCityName || \"到地\"} · ${caravan.statusLabel || \"状态未详\"}`,\n summary: caravan.publicSummary,\n extra: compactList((caravan.cargoLots || []).map((lot) => lot.displayName), \"未见货批\"),\n metrics: [\n [\"状态\", caravan.statusLabel],\n [\"货重\", `${numberValue(caravan.usedWeight, 0)}/${numberValue(caravan.capacityWeight, 0)}`],\n [\"盈亏\", `${numberValue(caravan.profitCopper, 0)}文`],\n [\"到期\", caravan.dueTurn === null || caravan.dueTurn === undefined ? \"未启运\" : `第${caravan.dueTurn}回`]\n ],\n tags: [caravan.statusLabel, caravan.riskLabel, ...(caravan.riskTags || []), ...(caravan.conflictFlags || [])],\n routeId: caravan.routeId,\n risk: caravan.riskScore,\n turn: caravan.updatedTurn,\n sortValue: Math.max(numberValue(caravan.riskScore), numberValue(caravan.updatedTurn))\n }));\n }\n for (const contract of Array.isArray(tradeRoutes.contractRows) ? tradeRoutes.contractRows : []) {\n rows.push(makePageRow({\n id: `economic-contract:${contract.contractId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.contractRows\",\n kind: \"economic_contract\",\n kindLabel: \"经济契约\",\n filterValue: \"economic_contract\",\n title: `${contract.counterpartyLabel}契约`,\n meta: `${contract.contractType || \"契约\"} · ${contract.statusLabel || \"履约\"}`,\n summary: contract.publicSummary,\n extra: contract.serverBoundary,\n metrics: [\n [\"契约额\", `${numberValue(contract.amountCopper, 0)}文`],\n [\"到期\", contract.dueTurn === null || contract.dueTurn === undefined ? \"未定\" : `第${contract.dueTurn}回`]\n ],\n tags: [contract.contractType, contract.statusLabel, ...(contract.riskTags || [])],\n risk: contract.status === \"breached\" ? 88 : 46,\n turn: contract.updatedTurn,\n sortValue: contract.status === \"breached\" ? 88 : numberValue(contract.updatedTurn)\n }));\n }\n for (const settlement of Array.isArray(tradeRoutes.settlements) ? tradeRoutes.settlements : []) {\n rows.push(makePageRow({\n id: `caravan-settlement:${settlement.settlementId}`,\n tabId: \"trade-routes\",\n sourceView: \"tradeRouteView.settlements\",\n kind: \"caravan_settlement\",\n kindLabel: \"交割复盘\",\n filterValue: \"caravan_settlement\",\n title: \"商队交割复盘\",\n meta: settlement.status || \"settled\",\n summary: settlement.publicSummary,\n metrics: [\n [\"入账\", `${numberValue(settlement.creditedSilverLiang, 0)}两`],\n [\"盈亏\", `${numberValue(settlement.profitCopper, 0)}文`],\n [\"成本\", `${numberValue(settlement.totalCostCopper, 0)}文`]\n ],\n tags: settlement.riskTags || [],\n risk: settlement.profitCopper < 0 ? 70 : 35,\n turn: settlement.turn,\n sortValue: numberValue(settlement.turn)\n }));\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"trade-routes\") return buildTradeRouteRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState),\n tradeRouteView: views.tradeRouteView || buildTradeRouteView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "bd4d3f531ec08f5f352aa01b0335d52b5ea47d70": {"t": 1785753946.1443388, "n": 37464, "h": "4767632d3b77d52f", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildCityEconomyView } = require(\"./cityMarket\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"city-economy\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n city_market: \"城市市面\",\n market_good: \"市场商品\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"city-economy\": \"city_market\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"city-economy\": \"pressure\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildCityEconomyRows(worldState, views) {\n const cityEconomy = views.cityEconomyView || {};\n const cityRows = Array.isArray(cityEconomy.cityRows)\n ? cityEconomy.cityRows\n : Array.isArray(cityEconomy.items)\n ? cityEconomy.items\n : [];\n const rows = [];\n for (const city of cityRows) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows\",\n kind: \"city_market\",\n kindLabel: \"城市市面\",\n filterValue: \"city_market\",\n title: `${city.cityName}市面`,\n meta: `${city.prosperityLabel || \"市面\"} ${city.marketPressureLabel || \"\"}`,\n summary: city.publicSummary,\n extra: `特产:${compactList((city.specialtyGoods || []).map((goods) => goods.displayName), \"民生日用\")}`,\n metrics: [\n [\"市面繁荣\", city.prosperityIndex],\n [\"供需压力\", city.marketPressure],\n [\"仓储余裕\", city.stockAdequacy],\n [\"商贸指数\", city.tradeIndex],\n [\"民生日用\", city.livelihoodIndex]\n ],\n tags: [city.prosperityLabel, city.marketPressureLabel, city.stockAdequacyLabel, ...(city.cityTags || [])],\n cityId: city.cityId,\n pressure: city.marketPressure,\n risk: 100 - numberValue(city.livelihoodIndex, 50),\n turn: city.lastUpdatedTurn,\n sortValue: city.marketPressure\n }));\n for (const goods of (Array.isArray(city.goodsMarkets) ? city.goodsMarkets : []).slice(0, 4)) {\n rows.push(makePageRow({\n id: `city-market:${city.cityId}:${goods.goodsId}`,\n tabId: \"city-economy\",\n sourceView: \"cityEconomyView.cityRows.goodsMarkets\",\n kind: \"market_good\",\n kindLabel: \"市场商品\",\n filterValue: \"market_good\",\n title: `${city.cityName}:${goods.displayName}`,\n meta: `${goods.stockStatus || \"库存\"} ${goods.legalStatus || \"ordinary\"}`,\n summary: `${goods.displayName}公开库存估计为${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"},供给${numberValue(goods.supplyIndex, 0)},需求${numberValue(goods.demandIndex, 0)};只作案卷参考。`,\n extra: goods.settlementBoundary,\n metrics: [\n [\"库存\", `${numberValue(goods.stockQuantity, 0)}${goods.unitLabel || \"\"}`],\n [\"供给\", goods.supplyIndex],\n [\"需求\", goods.demandIndex],\n [\"估价\", `${numberValue(goods.priceCopperEstimate, 0)}文`]\n ],\n tags: [goods.category, goods.goodsTier, goods.legalStatus, goods.specialLocalGood ? \"特产\" : \"\"],\n cityId: city.cityId,\n pressure: goods.demandIndex,\n risk: goods.legalStatus === \"ordinary\" ? city.marketPressure : Math.max(city.marketPressure || 0, 72),\n turn: city.lastUpdatedTurn,\n sortValue: goods.demandIndex\n }));\n }\n }\n return rows;\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"city-economy\") return buildCityEconomyRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState),\n cityEconomyView: views.cityEconomyView || buildCityEconomyView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "6f156075a87d1df0632cfbef6808cafe4c0db329": {"t": 1785753947.5288277, "n": 34397, "h": "980c4140232ae4aa", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildPlayerWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildPlayerWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}, "efa5fa114258f10097ffe8ac7f6e20b029059d80": {"t": 1785753949.9507918, "n": 34385, "h": "b3e0d10f4c561842", "c": "const { buildEventArchiveIndexItems } = require(\"./eventArchive\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildWorldGeographyView } = require(\"./worldGeography\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\n\nconst INFORMATION_PANEL_PAGE_SCHEMA_VERSION = 1;\nconst INFORMATION_PANEL_PAGE_TABS = Object.freeze([\n \"world-geography\",\n \"posting-geography\",\n \"world-people\",\n \"official-postings\",\n \"event-archive\"\n]);\nconst INFORMATION_PANEL_MIN_PAGE_SIZE = 1;\nconst INFORMATION_PANEL_DEFAULT_PAGE_SIZE = 8;\nconst INFORMATION_PANEL_MAX_PAGE_SIZE = 24;\nconst INFORMATION_PANEL_MAX_QUERY_LENGTH = 48;\nconst INFORMATION_PANEL_MAX_TEXT_LENGTH = 160;\n\nconst SECRET_ENV_NAME_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i;\nconst SENSITIVE_INFORMATION_TEXT_PATTERN =\n /(hiddenNotes|hiddenIntent|relationshipLedger|retrievalContext|statePatch|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|world_sessions|world_state_json|prompt_retrieval_index|event_archive_index|raw[_ -]?(?:table|ledger|audit)|(?:geo|people|office)_[A-Za-z0-9_]+|\\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*\\b|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+|sk-[A-Za-z0-9_-]{8,}|tp-[A-Za-z0-9_-]{8,})/i;\n\nconst KIND_LABELS = Object.freeze({\n country: \"国家\",\n city: \"城市\",\n route: \"路线\",\n frontier: \"边面\",\n \"office-jurisdiction\": \"地理辖区\",\n posting: \"任命\",\n jurisdiction: \"辖区\",\n npc: \"人物\",\n household: \"家族\",\n asset: \"资产\",\n estate: \"田产\",\n relationship: \"关系\",\n bureau: \"官署\",\n office: \"官职\",\n assessment: \"考成\",\n transfer: \"迁转\",\n event_history: \"近事\",\n world_thread: \"议程\",\n long_term_event: \"长期\",\n official_career: \"官场\",\n official_assessment: \"考成\",\n local_docket: \"案牍\",\n military_diplomacy: \"军务\",\n economic_fiscal: \"财赋\",\n historical_event_chain: \"事件链\",\n intelligence_rumor: \"情报\",\n exam_record: \"科场\"\n});\n\nconst SORT_LABELS = Object.freeze({\n default: \"默认\",\n pressure: \"压力\",\n risk: \"风险\",\n turn: \"回合\",\n name: \"名称\"\n});\n\nconst DEFAULT_TAB_FILTERS = Object.freeze({\n \"world-geography\": \"all\",\n \"posting-geography\": \"all\",\n \"world-people\": \"npc\",\n \"official-postings\": \"all\",\n \"event-archive\": \"all\"\n});\n\nconst DEFAULT_TAB_SORTS = Object.freeze({\n \"world-geography\": \"pressure\",\n \"posting-geography\": \"risk\",\n \"world-people\": \"risk\",\n \"official-postings\": \"risk\",\n \"event-archive\": \"turn\"\n});\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizePage(value) {\n return clampNumber(value, 1, Number.MAX_SAFE_INTEGER, 1);\n}\n\nfunction normalizePageSize(value) {\n return clampNumber(\n value,\n INFORMATION_PANEL_MIN_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE\n );\n}\n\nfunction redactPanelText(value) {\n let text = String(value ?? \"\");\n for (const [envName, secret] of Object.entries(process.env)) {\n if (!SECRET_ENV_NAME_PATTERN.test(envName) || !secret || String(secret).length < 8) continue;\n const raw = String(secret);\n const variants = new Set([raw, raw.slice(0, 8), raw.slice(0, 12), raw.slice(-8), raw.slice(-12)]);\n for (const variant of variants) {\n if (variant && variant.length >= 8) text = text.split(variant).join(\"[redacted]\");\n }\n }\n\n text = text.replace(/\\bsk-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/\\btp-[A-Za-z0-9_-]{8,}\\b/g, \"[redacted]\");\n text = text.replace(/[A-Za-z]:\\\\[^\\s\"'<>]+/g, \"[redacted-path]\");\n text = text.replace(/(^|\\s)(?:\\.{0,2}[\\\\/])?data[\\\\/][^\\s\"'<>]+/g, \"$1[redacted-path]\");\n text = text.replace(/\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+/g, \"[redacted-path]\");\n return text;\n}\n\nfunction cleanPanelText(value, fallback = \"\", maxLength = INFORMATION_PANEL_MAX_TEXT_LENGTH) {\n const raw = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n if (!raw) return fallback;\n const redacted = redactPanelText(raw).replace(/\\s+/g, \" \").trim();\n if (!redacted || SENSITIVE_INFORMATION_TEXT_PATTERN.test(redacted)) return fallback;\n return redacted.length > maxLength ? `${redacted.slice(0, maxLength)}...` : redacted;\n}\n\nfunction normalizeTabId(value) {\n const candidate = typeof value === \"string\" ? value.trim() : \"\";\n return INFORMATION_PANEL_PAGE_TABS.includes(candidate) ? candidate : \"world-geography\";\n}\n\nfunction normalizeFilter(value) {\n const candidate = cleanPanelText(value, \"all\", 48).toLowerCase();\n return candidate || \"all\";\n}\n\nfunction normalizeSort(value) {\n const candidate = typeof value === \"string\" ? value.trim().toLowerCase() : \"\";\n return SORT_LABELS[candidate] ? candidate : \"default\";\n}\n\nfunction normalizeQuery(value) {\n const raw = typeof value === \"string\" ? value.replace(/\\s+/g, \" \").trim() : \"\";\n if (!raw) return { query: \"\", rejected: false };\n if (SENSITIVE_INFORMATION_TEXT_PATTERN.test(raw)) return { query: \"\", rejected: true };\n return {\n query: cleanPanelText(raw.slice(0, INFORMATION_PANEL_MAX_QUERY_LENGTH), \"\", INFORMATION_PANEL_MAX_QUERY_LENGTH),\n rejected: false\n };\n}\n\nfunction metric(label, value) {\n if (value === undefined || value === null || value === \"\") return null;\n return {\n label: cleanPanelText(label, \"项\", 24),\n value: cleanPanelText(value, \"未明\", 60)\n };\n}\n\nfunction compactList(values = [], fallback = \"\", limit = 4) {\n const list = Array.isArray(values)\n ? values.map((value) => cleanPanelText(value, \"\", 48)).filter(Boolean)\n : [];\n return list.length ? list.slice(0, limit).join(\"、\") : fallback;\n}\n\nfunction numberValue(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction rowName(rows = [], id, fallback = \"未明\") {\n if (!id) return fallback;\n const row = rows.find((entry) => entry?.id === id);\n return cleanPanelText(row?.name || row?.familyName || row?.shortName || row?.title || row?.officeTitle, fallback, 80);\n}\n\nfunction makePageRow(fields = {}) {\n const title = cleanPanelText(fields.title, \"未名条目\", 80);\n const meta = cleanPanelText(fields.meta, KIND_LABELS[fields.filterValue] || KIND_LABELS[fields.kind] || \"\", 80);\n const summary = cleanPanelText(fields.summary, \"暂无公开案语。\");\n const extra = cleanPanelText(fields.extra, \"\", 120);\n const tags = Array.isArray(fields.tags)\n ? fields.tags.map((tag) => cleanPanelText(tag, \"\", 40)).filter(Boolean).slice(0, 6)\n : [];\n const metrics = Array.isArray(fields.metrics)\n ? fields.metrics.map(([label, value]) => metric(label, value)).filter(Boolean).slice(0, 6)\n : [];\n const filterValue = cleanPanelText(fields.filterValue || fields.kind, \"item\", 48).toLowerCase();\n const kind = cleanPanelText(fields.kind || filterValue, \"item\", 48);\n const row = {\n id: cleanPanelText(fields.id, `${kind}-item`, 80),\n tabId: fields.tabId,\n sourceView: cleanPanelText(fields.sourceView, \"\", 80),\n kind,\n kindLabel: cleanPanelText(fields.kindLabel || KIND_LABELS[filterValue] || KIND_LABELS[kind], kind, 32),\n filterValue,\n title,\n meta,\n summary,\n metrics,\n extra,\n tags,\n status: cleanPanelText(fields.status, \"\", 48),\n statusLabel: cleanPanelText(fields.statusLabel, \"\", 48),\n visibility: cleanPanelText(fields.visibility, \"public\", 32),\n cityId: cleanPanelText(fields.cityId, \"\", 80),\n routeId: cleanPanelText(fields.routeId, \"\", 80),\n pressure: fields.pressure === undefined ? null : numberValue(fields.pressure),\n risk: fields.risk === undefined ? null : numberValue(fields.risk),\n turn: fields.turn === undefined ? null : numberValue(fields.turn),\n year: fields.year === undefined ? null : numberValue(fields.year),\n month: fields.month === undefined ? null : numberValue(fields.month),\n tenDayPeriod: fields.tenDayPeriod === undefined ? null : numberValue(fields.tenDayPeriod),\n dateLabel: cleanPanelText(fields.dateLabel, \"\", 80),\n sortValue: numberValue(fields.sortValue, 0)\n };\n row.searchText = [\n row.kindLabel,\n row.title,\n row.meta,\n row.summary,\n row.extra,\n row.statusLabel,\n ...row.tags,\n ...row.metrics.flatMap((entry) => [entry.label, entry.value])\n ].filter(Boolean).join(\" \").toLowerCase();\n return row;\n}\n\nfunction publicRow(row) {\n const { searchText, ...publicFields } = row;\n return publicFields;\n}\n\nfunction buildWorldGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const countries = Array.isArray(geography.countries) ? geography.countries : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const frontiers = Array.isArray(geography.frontierZones) ? geography.frontierZones : [];\n const jurisdictions = Array.isArray(geography.officeJurisdictions) ? geography.officeJurisdictions : [];\n\n return [\n ...countries.map((country) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.countries\",\n id: country.id,\n kind: \"country\",\n filterValue: \"country\",\n title: country.name,\n meta: `国家 · ${country.statusLabel || country.status || \"未详\"}`,\n summary: country.publicSummary,\n metrics: [\n [\"压力\", country.pressure],\n [\"安稳\", country.stability],\n [\"信度\", country.intelConfidence]\n ],\n extra: compactList(country.policyPressureTags || country.cultureTags, \"\"),\n status: country.status,\n statusLabel: country.statusLabel,\n visibility: country.visibility,\n pressure: country.pressure,\n sortValue: country.pressure\n })),\n ...cities.map((city) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.cities\",\n id: city.id,\n kind: \"city\",\n filterValue: \"city\",\n title: city.name,\n meta: `${rowName(countries, city.countryId, \"未知邦国\")} · ${city.statusLabel || city.status || \"未详\"}`,\n summary: city.publicSummary,\n metrics: [\n [\"压力\", city.pressure],\n [\"民心\", city.localOrder],\n [\"粮压\", city.grainStress]\n ],\n extra: compactList(city.strategicTags || city.localIssueTags, city.terrain || \"\"),\n status: city.status,\n statusLabel: city.statusLabel,\n visibility: city.visibility,\n cityId: city.id,\n pressure: city.pressure,\n sortValue: city.pressure\n })),\n ...routes.map((route) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `路线 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"里程\", route.distanceLabel],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`]\n ],\n extra: route.seasonalRisk || compactList(route.strategicTags, \"\"),\n status: route.status,\n statusLabel: route.statusLabel,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n })),\n ...frontiers.map((frontier) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.frontierZones\",\n id: frontier.id,\n kind: \"frontier\",\n filterValue: \"frontier\",\n title: frontier.name,\n meta: `边面 · ${frontier.statusLabel || frontier.status || \"未详\"}`,\n summary: frontier.publicSummary,\n metrics: [\n [\"压力\", frontier.pressure],\n [\"邻境\", rowName(countries, frontier.neighborCountryId)],\n [\"路线\", compactList((frontier.routeIds || []).map((id) => rowName(routes, id, \"\")), \"暂无\")]\n ],\n extra: compactList((frontier.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n status: frontier.status,\n statusLabel: frontier.statusLabel,\n visibility: frontier.visibility,\n pressure: frontier.pressure,\n sortValue: frontier.pressure\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"world-geography\",\n sourceView: \"worldGeographyView.officeJurisdictions\",\n id: jurisdiction.id,\n kind: \"office-jurisdiction\",\n filterValue: \"office-jurisdiction\",\n title: jurisdiction.name,\n meta: `辖区 · ${jurisdiction.scope || \"未详\"}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"优先\", jurisdiction.priority],\n [\"城市\", (jurisdiction.cityIds || []).length],\n [\"路线\", (jurisdiction.routeIds || []).length]\n ],\n extra: compactList((jurisdiction.cityIds || []).map((id) => rowName(cities, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n pressure: jurisdiction.priority,\n sortValue: jurisdiction.priority\n }))\n ];\n}\n\nfunction buildPostingGeographyRows(worldState, views) {\n const geography = views.worldGeographyView || {};\n const postings = views.officialPostingsView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const routes = Array.isArray(geography.routes) ? geography.routes : [];\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const jurisdictions = Array.isArray(postings.cityJurisdictions) ? postings.cityJurisdictions : [];\n const routeIds = new Set(jurisdictions.flatMap((row) => row.routeIds || []));\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle,\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${rowName(cities, posting.cityId, \"未明城市\")}`,\n summary: posting.publicSummary,\n metrics: [\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", `${posting.termMonths ?? 0}月`]\n ],\n extra: rowName(jurisdictions, posting.jurisdictionId, \"\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...jurisdictions.map((jurisdiction) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"officialPostingsView.cityJurisdictions\",\n id: jurisdiction.id,\n kind: \"jurisdiction\",\n filterValue: \"jurisdiction\",\n title: jurisdiction.name,\n meta: `${rowName(bureaus, jurisdiction.bureauId, \"未明官署\")} · ${rowName(cities, jurisdiction.cityId, \"未明城市\")}`,\n summary: jurisdiction.publicSummary,\n metrics: [\n [\"民心\", jurisdiction.localMetrics?.publicOrder],\n [\"税力\", jurisdiction.localMetrics?.taxCapacity],\n [\"词讼\", jurisdiction.localMetrics?.lawsuits],\n [\"水利\", jurisdiction.localMetrics?.waterworks]\n ],\n extra: compactList((jurisdiction.routeIds || []).map((id) => rowName(routes, id, \"\")), \"\"),\n visibility: jurisdiction.visibility,\n cityId: jurisdiction.cityId,\n risk: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure)),\n sortValue: Math.max(numberValue(jurisdiction.localMetrics?.disasterRisk), numberValue(jurisdiction.localMetrics?.militaryPressure))\n })),\n ...routes.filter((route) => routeIds.has(route.id)).map((route) => makePageRow({\n tabId: \"posting-geography\",\n sourceView: \"worldGeographyView.routes\",\n id: route.id,\n kind: \"route\",\n filterValue: \"route\",\n title: route.name,\n meta: `任所通路 · ${route.statusLabel || route.status || \"未详\"}`,\n summary: route.publicSummary,\n metrics: [\n [\"风险\", route.risk],\n [\"端点\", `${rowName(cities, route.fromCityId)}至${rowName(cities, route.toCityId)}`],\n [\"里程\", route.distanceLabel]\n ],\n extra: route.seasonalRisk,\n status: route.status,\n visibility: route.visibility,\n routeId: route.id,\n risk: route.risk,\n sortValue: route.risk\n }))\n ];\n}\n\nfunction buildWorldPeopleRows(worldState, views) {\n const people = views.worldPeopleView || {};\n const geography = views.worldGeographyView || {};\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n const npcs = Array.isArray(people.npcs) ? people.npcs : [];\n const households = Array.isArray(people.households) ? people.households : [];\n const assets = Array.isArray(people.assets) ? people.assets : [];\n const estates = Array.isArray(people.estates) ? people.estates : [];\n\n const ownerName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n return \"未明归属\";\n };\n const endpointName = (type, id) => {\n if (type === \"player\") return cleanPanelText(worldState.player?.name, \"玩家\", 80);\n if (type === \"npc\") return rowName(npcs, id, \"未明人物\");\n if (type === \"household\") return rowName(households, id, \"未明家族\");\n if (type === \"asset\") return rowName(assets, id, \"未明资产\");\n if (type === \"estate\") return rowName(estates, id, \"未明田产\");\n return \"可见关系\";\n };\n\n return [\n ...npcs.map((npc) => {\n const risk = Math.max(numberValue(npc.resentmentRisk), numberValue(npc.legalRisk), numberValue(npc.impeachmentRisk));\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.npcs\",\n id: npc.id,\n kind: \"npc\",\n filterValue: \"npc\",\n title: npc.courtesyName ? `${npc.name}(字${npc.courtesyName})` : npc.name,\n meta: `${npc.rankLabel || \"可见人物\"} · ${npc.alive === false ? \"故\" : \"在世\"}`,\n summary: npc.publicSummary,\n metrics: [\n [\"声望\", npc.reputation],\n [\"影响\", npc.influence],\n [\"怨险\", risk]\n ],\n extra: npc.currentGoal || compactList(npc.ideologyTags, \"\"),\n visibility: npc.visibility,\n risk,\n sortValue: Math.max(numberValue(npc.influence), numberValue(npc.reputation), risk)\n });\n }),\n ...households.map((household) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.households\",\n id: household.id,\n kind: \"household\",\n filterValue: \"household\",\n title: `${household.familyName || \"未名\"}氏`,\n meta: `${rowName(cities, household.seatCityId, \"未明郡邑\")} · ${household.gentryRank || \"家声未详\"}`,\n summary: household.publicSummary,\n metrics: [\n [\"家资\", household.wealthScore],\n [\"声望\", household.prestige],\n [\"债压\", household.debtPressure]\n ],\n extra: household.politicalAlignment || compactList((household.memberNpcIds || []).map((id) => rowName(npcs, id, \"\")), \"\"),\n visibility: household.visibility,\n risk: Math.max(numberValue(household.familyRisk), numberValue(household.debtPressure)),\n sortValue: Math.max(numberValue(household.wealthScore), numberValue(household.prestige), numberValue(household.familyRisk))\n })),\n ...assets.map((asset) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.assets\",\n id: asset.id,\n kind: \"asset\",\n filterValue: \"asset\",\n title: asset.name,\n meta: `${asset.kind || \"资产\"} · ${rowName(cities, asset.cityId, \"未明郡邑\")}`,\n summary: asset.publicSummary,\n metrics: [\n [\"估值\", asset.valueEstimate],\n [\"岁入\", asset.annualIncomeEstimate],\n [\"负债\", asset.debtValue]\n ],\n extra: `归属:${ownerName(asset.ownerType, asset.ownerId)};情状:${asset.statusLabel || \"未详\"}`,\n status: asset.statusLabel,\n visibility: asset.visibility,\n cityId: asset.cityId,\n risk: asset.debtValue,\n sortValue: Math.max(numberValue(asset.valueEstimate), numberValue(asset.annualIncomeEstimate), numberValue(asset.debtValue))\n })),\n ...estates.map((estate) => makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.estates\",\n id: estate.id,\n kind: \"estate\",\n filterValue: \"estate\",\n title: estate.name,\n meta: rowName(cities, estate.cityId, \"未明郡邑\"),\n summary: estate.publicSummary,\n metrics: [\n [\"田亩\", estate.landMu ? `${estate.landMu}亩` : \"\"],\n [\"租谷\", estate.rentGrainEstimate],\n [\"讼险\", estate.disputeRisk]\n ],\n extra: `归属:${ownerName(estate.ownerType, estate.ownerId)};水利:${estate.waterworks ?? \"未详\"}`,\n status: estate.status,\n visibility: estate.visibility,\n cityId: estate.cityId,\n risk: estate.disputeRisk,\n sortValue: Math.max(numberValue(estate.landMu), numberValue(estate.disputeRisk), numberValue(estate.taxBurden))\n })),\n ...(Array.isArray(people.relationships) ? people.relationships : []).map((relationship) => {\n const risk = Math.max(numberValue(relationship.resentment), numberValue(relationship.rivalry), numberValue(relationship.fear));\n const source = endpointName(relationship.sourceType, relationship.sourceId);\n const target = endpointName(relationship.targetType, relationship.targetId);\n return makePageRow({\n tabId: \"world-people\",\n sourceView: \"worldPeopleView.relationships\",\n id: relationship.id,\n kind: \"relationship\",\n filterValue: \"relationship\",\n title: `${source}与${target}`,\n meta: relationship.stance || \"关系可见\",\n summary: relationship.publicSummary,\n metrics: [\n [\"情分\", relationship.relationship],\n [\"信任\", relationship.trust],\n [\"怨望\", relationship.resentment]\n ],\n extra: relationship.recentIntent || compactList(relationship.recentNotes, \"\", 2),\n visibility: relationship.visibility,\n risk,\n sortValue: Math.max(risk, 100 - numberValue(relationship.trust, 50))\n });\n })\n ];\n}\n\nfunction buildOfficialPostingsRows(worldState, views) {\n const postings = views.officialPostingsView || {};\n const geography = views.worldGeographyView || {};\n const bureaus = Array.isArray(postings.bureaus) ? postings.bureaus : [];\n const offices = Array.isArray(postings.offices) ? postings.offices : [];\n const cities = Array.isArray(geography.cities) ? geography.cities : [];\n\n return [\n ...(Array.isArray(postings.postings) ? postings.postings : []).map((posting) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.postings\",\n id: posting.id,\n kind: \"posting\",\n filterValue: \"posting\",\n title: posting.officeTitle || rowName(offices, posting.officeId, \"任命\"),\n meta: `${rowName(bureaus, posting.bureauId, \"未明官署\")} · ${posting.holderType || \"未详\"}`,\n summary: posting.publicSummary,\n metrics: [\n [\"状态\", posting.status],\n [\"考成\", posting.performanceScore],\n [\"弹劾\", posting.impeachmentRisk],\n [\"任期\", posting.termMonths ? `${posting.termMonths}月` : \"0月\"]\n ],\n extra: rowName(cities, posting.cityId, \"未明城市\"),\n status: posting.status,\n visibility: posting.visibility,\n cityId: posting.cityId,\n risk: posting.impeachmentRisk,\n sortValue: Math.max(numberValue(posting.performanceScore), numberValue(posting.impeachmentRisk))\n })),\n ...(Array.isArray(postings.assessmentRecords) ? postings.assessmentRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.assessmentRecords\",\n id: record.id,\n kind: \"assessment\",\n filterValue: \"assessment\",\n title: `${rowName(offices, record.officeId, \"官职\")}考成`,\n meta: `${rowName(bureaus, record.bureauId, \"未明官署\")} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicFinding || record.publicSummary,\n metrics: [\n [\"功绩\", record.meritScore],\n [\"风险\", record.riskScore],\n [\"建议\", record.recommendation],\n [\"状态\", record.status]\n ],\n extra: `差遣据数:${(record.assignmentIds || []).length}`,\n status: record.status,\n visibility: record.visibility,\n risk: record.riskScore,\n turn: record.date?.turn,\n sortValue: Math.max(numberValue(record.meritScore), numberValue(record.riskScore))\n })),\n ...bureaus.map((bureau) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.bureaus\",\n id: bureau.id,\n kind: \"bureau\",\n filterValue: \"bureau\",\n title: bureau.name,\n meta: `官署 · ${bureau.level || \"未详\"}`,\n summary: bureau.publicSummary,\n metrics: [\n [\"官职\", (bureau.officeIds || []).length],\n [\"辖区\", (bureau.jurisdictionIds || []).length],\n [\"信度\", bureau.intelConfidence]\n ],\n extra: compactList(bureau.duties, compactList(bureau.riskTags, \"\")),\n visibility: bureau.visibility,\n sortValue: Math.max(numberValue(bureau.intelConfidence), (bureau.officeIds || []).length)\n })),\n ...offices.map((office) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.offices\",\n id: office.id,\n kind: \"office\",\n filterValue: \"office\",\n title: office.title,\n meta: `${rowName(bureaus, office.bureauId, \"未明官署\")} · ${office.rankLabel || office.rankBand || \"品秩未详\"}`,\n summary: office.publicSummary,\n metrics: [\n [\"任期\", office.normalTermMonths ? `${office.normalTermMonths}月` : \"未定\"],\n [\"职掌\", (office.duties || []).length],\n [\"路径\", (office.promotionPathIds || []).length]\n ],\n extra: compactList(office.duties, office.requiredRankOrExam || \"\"),\n visibility: office.visibility,\n sortValue: Math.max(numberValue(office.normalTermMonths), (office.duties || []).length)\n })),\n ...(Array.isArray(postings.transferRecords) ? postings.transferRecords : []).map((record) => makePageRow({\n tabId: \"official-postings\",\n sourceView: \"officialPostingsView.transferRecords\",\n id: record.id,\n kind: \"transfer\",\n filterValue: \"transfer\",\n title: `${rowName(offices, record.fromOfficeId, \"未授\")}至${rowName(offices, record.toOfficeId, \"未授\")}`,\n meta: `${record.type || \"迁转\"} · ${record.date?.dateLabel || \"本期\"}`,\n summary: record.publicReason || record.publicSummary,\n metrics: [\n [\"状态\", record.status],\n [\"起地\", rowName(cities, record.fromCityId, \"未详\")],\n [\"赴地\", rowName(cities, record.toCityId, \"未详\")]\n ],\n status: record.status,\n visibility: record.visibility,\n cityId: record.toCityId,\n turn: record.date?.turn,\n sortValue: numberValue(record.date?.turn)\n }))\n ];\n}\n\nfunction buildEventArchiveRows(worldState) {\n return buildEventArchiveIndexItems(worldState).map((item) => makePageRow({\n tabId: \"event-archive\",\n sourceView: \"eventArchiveView.items\",\n id: item.id,\n kind: item.kind || item.sourceType,\n kindLabel: item.sourceLabel,\n filterValue: item.sourceType,\n title: item.title,\n meta: item.dateLabel || item.sourceLabel,\n summary: item.summary,\n metrics: [\n [\"来源\", item.sourceLabel || item.sourceType],\n [\"状态\", item.statusLabel || item.status],\n [\"回数\", `第${item.turn ?? 0}回`],\n [\"风险\", item.riskLabel]\n ],\n extra: compactList(item.relatedLabels, \"\"),\n status: item.status,\n statusLabel: item.statusLabel,\n visibility: item.visibility,\n risk: item.riskLabel ? 1 : 0,\n turn: item.turn,\n year: item.year,\n month: item.month,\n tenDayPeriod: item.tenDayPeriod,\n dateLabel: item.dateLabel,\n sortValue: item.turn\n })).map((row) => ({\n ...row,\n sourceType: row.filterValue\n }));\n}\n\nfunction buildAllRows(worldState, views, tabId) {\n if (tabId === \"world-geography\") return buildWorldGeographyRows(worldState, views);\n if (tabId === \"posting-geography\") return buildPostingGeographyRows(worldState, views);\n if (tabId === \"world-people\") return buildWorldPeopleRows(worldState, views);\n if (tabId === \"official-postings\") return buildOfficialPostingsRows(worldState, views);\n if (tabId === \"event-archive\") return buildEventArchiveRows(worldState, views);\n return [];\n}\n\nfunction compareDefault(first, second) {\n if (first.tabId === \"event-archive\") {\n return (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id);\n }\n return first.id.localeCompare(second.id);\n}\n\nfunction sortRows(rows, sort) {\n const sorted = rows.slice();\n if (sort === \"name\") {\n return sorted.sort((first, second) => first.title.localeCompare(second.title) || first.id.localeCompare(second.id));\n }\n if (sort === \"turn\") {\n return sorted.sort((first, second) => (second.turn ?? 0) - (first.turn ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"risk\") {\n return sorted.sort((first, second) => (second.risk ?? 0) - (first.risk ?? 0) || first.id.localeCompare(second.id));\n }\n if (sort === \"pressure\") {\n return sorted.sort((first, second) => (second.pressure ?? second.sortValue ?? 0) - (first.pressure ?? first.sortValue ?? 0) || first.id.localeCompare(second.id));\n }\n return sorted.sort(compareDefault);\n}\n\nfunction countFilters(rows) {\n const counts = rows.reduce((memo, row) => {\n memo[row.filterValue] = (memo[row.filterValue] || 0) + 1;\n return memo;\n }, {});\n return [\n { value: \"all\", label: \"全部\", count: rows.length },\n ...Object.entries(counts)\n .sort(([first], [second]) => first.localeCompare(second))\n .map(([value, count]) => ({\n value,\n label: KIND_LABELS[value] || value,\n count\n }))\n ];\n}\n\nfunction buildPagination(totalItems, page, pageSize) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const normalizedPage = Math.min(normalizePage(page), totalPages);\n return {\n page: normalizedPage,\n pageSize,\n totalItems,\n totalPages,\n hasPreviousPage: normalizedPage > 1,\n hasNextPage: normalizedPage < totalPages,\n offset: (normalizedPage - 1) * pageSize\n };\n}\n\nfunction buildSourceViews(worldState = {}, prebuiltViews = null) {\n const views = prebuiltViews && isPlainObject(prebuiltViews) ? prebuiltViews : {};\n return {\n worldGeographyView: views.worldGeographyView || buildWorldGeographyView(worldState),\n worldPeopleView: views.worldPeopleView || buildWorldPeopleView(worldState),\n officialPostingsView: views.officialPostingsView || buildOfficialPostingsView(worldState)\n };\n}\n\nfunction buildInformationPanelPageView(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const tabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const { query, rejected: queryRejected } = normalizeQuery(rawOptions.query);\n const filter = normalizeFilter(rawOptions.filter);\n const sort = normalizeSort(rawOptions.sort);\n const pageSize = normalizePageSize(rawOptions.pageSize);\n const views = buildSourceViews(worldState, prebuiltViews);\n const rows = buildAllRows(worldState, views, tabId);\n const queryRows = query\n ? rows.filter((row) => row.searchText.includes(query.toLowerCase()))\n : rows;\n const filters = countFilters(queryRows);\n const filterRows = filter === \"all\"\n ? queryRows\n : queryRows.filter((row) => row.filterValue === filter);\n const sortedRows = sortRows(filterRows, sort);\n const pagination = buildPagination(sortedRows.length, rawOptions.page, pageSize);\n const items = sortedRows.slice(pagination.offset, pagination.offset + pagination.pageSize).map(publicRow);\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n tabId,\n source: \"route_view_projection\",\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n query,\n queryRejected,\n filter,\n sort,\n sorts: Object.entries(SORT_LABELS).map(([value, label]) => ({ value, label })),\n filters,\n items,\n pagination: {\n page: pagination.page,\n pageSize: pagination.pageSize,\n totalItems: pagination.totalItems,\n totalPages: pagination.totalPages,\n hasPreviousPage: pagination.hasPreviousPage,\n hasNextPage: pagination.hasNextPage\n },\n counts: {\n totalAvailable: rows.length,\n queryMatched: queryRows.length,\n filtered: filterRows.length,\n pageItems: items.length\n },\n hiddenNotice: \"局势簿分页只读服务器整理的可见视图;本地诊断、密钥路径、模型提案和隐藏私档均已过滤。\"\n };\n}\n\nfunction buildInformationPanelPageViews(worldState = {}, rawOptions = {}, prebuiltViews = null) {\n const activeTabId = normalizeTabId(rawOptions.tabId || rawOptions.tab);\n const views = buildSourceViews(worldState, prebuiltViews);\n const cleanRawOptions = Object.fromEntries(\n Object.entries(rawOptions).filter(([, value]) => value !== undefined)\n );\n const defaultOptions = {\n page: 1,\n pageSize: INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n query: \"\"\n };\n const pages = INFORMATION_PANEL_PAGE_TABS.map((tabId) => {\n const tabDefaults = {\n filter: DEFAULT_TAB_FILTERS[tabId] || \"all\",\n sort: DEFAULT_TAB_SORTS[tabId] || \"default\"\n };\n const options = tabId === activeTabId\n ? { ...defaultOptions, ...tabDefaults, ...cleanRawOptions, tabId }\n : { ...defaultOptions, ...tabDefaults, tabId };\n return buildInformationPanelPageView(worldState, options, views);\n });\n const activePage = pages.find((page) => page.tabId === activeTabId) || pages[0];\n\n return {\n schemaVersion: INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n activeTabId,\n generatedAtTurn: clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0),\n pages,\n activePage,\n hiddenNotice: \"局势簿分页集合只含玩家可见服务器视图。\"\n };\n}\n\nfunction informationPanelOptionsFromQuery(query = {}) {\n return {\n tabId: query.informationTab ?? query.informationPanelTab ?? query.informationCollection,\n query: query.informationQuery ?? query.informationSearch,\n filter: query.informationFilter,\n sort: query.informationSort,\n page: query.informationPage,\n pageSize: query.informationPageSize\n };\n}\n\nmodule.exports = {\n INFORMATION_PANEL_DEFAULT_PAGE_SIZE,\n INFORMATION_PANEL_MAX_PAGE_SIZE,\n INFORMATION_PANEL_PAGE_SCHEMA_VERSION,\n buildInformationPanelPageView,\n buildInformationPanelPageViews,\n informationPanelOptionsFromQuery\n};\n"}}, "src/game/npcCohorts.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745555.5357273, "n": 39404, "h": "df9a73233adac3ad", "c": "const { getGoodsDefinition } = require(\"./goodsCatalog\");\nconst { assignPortraitRef, ensureNpcRoster } = require(\"./npcRoster\");\nconst { formatYearMonthPeriod } = require(\"./time\");\nconst {\n NPC_COHORT_CONFIG,\n NPC_COHORT_SCHEMA_VERSION,\n NPC_COHORT_SEED_RULES,\n NPC_COHORT_SHADOW_RULES,\n NPC_COHORT_VIEW_SCHEMA_VERSION\n} = require(\"./npcCohortConfig\");\n\nconst SAFE_TEXT_PATTERN =\n /(SEALED_[A-Z0-9_]+|hidden[_ -]?(?:notes?|intent|dossier)|hiddenDossier|privateSignalTags|raw[_ -]?(?:provider|audit|table|ledger|prompt|state|proposal|row)|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|safe_search_(?:index|fts)|ai_change_proposals|event_log|\\b(?:npcCohortLedger|npcEconomyLedger|npcRoster|marketPriceLedger|cityMarketLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|bankingLedger|conflictOfInterestLedger|assetLedger|resourceLedger|inventoryLedger|delegatedTaskLedger)\\b|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/[^\\s\"'<>]+)/i;\n\nconst STATUS_LABELS = Object.freeze({\n calm: \"平稳\",\n watch: \"观望\",\n pressured: \"承压\",\n mobilizing: \"聚众议价\",\n unrest: \"停工罢市风险\"\n});\n\nconst SEMI_NAME_PREFIXES = Object.freeze([\"赵\", \"钱\", \"孙\", \"李\", \"周\", \"吴\", \"郑\", \"王\", \"陆\", \"沈\", \"顾\", \"韩\"]);\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction normalizeScalar(value) {\n if (typeof value !== \"string\" && typeof value !== \"number\" && typeof value !== \"boolean\") return \"\";\n return String(value).replace(/\\s+/g, \" \").trim();\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = 160) {\n const text = normalizeScalar(value);\n if (!text || SAFE_TEXT_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 120);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction clampNumber(value, min, max, fallback = min) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction clampDecimal(value, min, max, fallback = min, precision = 2) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n const factor = 10 ** precision;\n return Math.max(min, Math.min(max, Math.round(parsed * factor) / factor));\n}\n\nfunction uniqueCleanList(values = [], limit = 8, maxLength = 80) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction uniqueGoodsIds(values = [], limit = 12) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const id = cleanId(value, \"\");\n if (!id || seen.has(id) || !getGoodsDefinition(id)) continue;\n seen.add(id);\n result.push(id);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction cloneJson(value) {\n return JSON.parse(JSON.stringify(value ?? null));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction deterministicIndex(seed, length) {\n if (!length) return 0;\n let total = 0;\n for (const char of String(seed || \"default\")) total += char.codePointAt(0) || 0;\n return total % length;\n}\n\nfunction rawGeographyCities(worldState = {}) {\n const raw = worldState.worldGeography;\n if (Array.isArray(raw?.cities)) return raw.cities;\n if (Array.isArray(raw?.seed?.cities)) return raw.seed.cities;\n return [];\n}\n\nfunction cityRowsFrom(worldState = {}, options = {}) {\n const cityEconomyRows = Array.isArray(options.cityEconomyView?.cityRows)\n ? options.cityEconomyView.cityRows\n : Array.isArray(options.cityEconomyView?.items)\n ? options.cityEconomyView.items\n : [];\n const cityRows = cityEconomyRows.length\n ? cityEconomyRows.map((city) => ({\n cityId: cleanId(city.cityId || city.id, \"\"),\n cityName: cleanText(city.cityName || city.name, \"\", 60),\n marketPressure: clampNumber(city.marketPressure, 0, 100, 50),\n prosperityIndex: clampNumber(city.prosperityIndex, 0, 100, 50),\n stockAdequacy: clampNumber(city.stockAdequacy, 0, 100, 50)\n }))\n : rawGeographyCities(worldState).map((city) => ({\n cityId: cleanId(city.id || city.cityId, \"\"),\n cityName: cleanText(city.name || city.cityName, \"\", 60),\n marketPressure: clampNumber(city.marketPriceStress || city.pressure, 0, 100, 45),\n prosperityIndex: clampNumber(city.taxBase || city.prosperityIndex, 0, 100, 50),\n stockAdequacy: clampNumber(city.grainStock || city.stockAdequacy, 0, 100, 50)\n }));\n const filtered = cityRows.filter((city) => city.cityId && city.cityName);\n if (filtered.length) return filtered;\n return [{\n cityId: cleanId(worldState.player?.locationId || \"home\", \"home\"),\n cityName: cleanText(worldState.player?.locationName || \"本地\", \"本地\", 60),\n marketPressure: 45,\n prosperityIndex: 50,\n stockAdequacy: 50\n }];\n}\n\nfunction cityById(rows = []) {\n return new Map(rows.map((row) => [row.cityId, row]));\n}\n\nfunction statusForPressure(pressure) {\n if (pressure >= 86) return \"unrest\";\n if (pressure >= 74) return \"mobilizing\";\n if (pressure >= 58) return \"pressured\";\n if (pressure >= 38) return \"watch\";\n return \"calm\";\n}\n\nfunction goodsLabels(goodsIds = []) {\n return goodsIds\n .map((goodsId) => getGoodsDefinition(goodsId)?.displayName || \"\")\n .filter(Boolean)\n .slice(0, 4);\n}\n\nfunction buildCohortPublicSummary(cohort) {\n const goods = goodsLabels(cohort.goodsIds).join(\"、\") || \"市面要货\";\n return `${cohort.homeCityName}${cohort.displayName}约${cohort.population}人户,关注${goods};满意${cohort.satisfaction},影响${cohort.influence},当前${cohort.statusLabel}。`;\n}\n\nfunction normalizeCohort(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const cohortId = cleanId(raw.cohortId || raw.id, \"\");\n if (!cohortId) return null;\n const cohortType = cleanId(raw.cohortType, \"market_cohort\");\n const goodsIds = uniqueGoodsIds(raw.goodsIds, 12);\n const status = cleanId(raw.status, \"watch\");\n const cohort = {\n cohortId,\n archetypeId: cleanId(raw.archetypeId, cohortId),\n actorTier: \"cohort\",\n cohortType,\n displayName: cleanText(raw.displayName || raw.name, \"市面群体\", 80),\n representativeTitle: cleanText(raw.representativeTitle, \"行头\", 60),\n homeCityId: cleanId(raw.homeCityId || raw.cityId, \"home\"),\n homeCityName: cleanText(raw.homeCityName || raw.cityName, \"本地\", 60),\n population: clampNumber(raw.population, 1, 1000000, 1),\n wealthIndex: clampNumber(raw.wealthIndex, 0, 100, 50),\n satisfaction: clampNumber(raw.satisfaction, 0, 100, 50),\n influence: clampNumber(raw.influence, 0, 100, 45),\n demandIndex: clampNumber(raw.demandIndex, 0, 100, 50),\n supplyIndex: clampNumber(raw.supplyIndex, 0, 100, 50),\n pressureScore: clampNumber(raw.pressureScore, 0, 100, 50),\n status,\n statusLabel: cleanText(raw.statusLabel, STATUS_LABELS[status] || \"已登记\", 40),\n goodsIds,\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n tags: uniqueCleanList(raw.tags, 8, 40),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n publicSummary: cleanText(raw.publicSummary, \"\", 220)\n };\n cohort.publicSummary = cohort.publicSummary || buildCohortPublicSummary(cohort);\n return cohort;\n}\n\nfunction buildCohortFromRule(rule, city, index, worldState = {}) {\n const cohortId = `cohort:${cleanId(city.cityId, \"home\")}:${rule.archetypeId}`;\n return normalizeCohort({\n cohortId,\n archetypeId: rule.archetypeId,\n cohortType: rule.cohortType,\n displayName: rule.displayName,\n representativeTitle: rule.representativeTitle,\n homeCityId: city.cityId,\n homeCityName: city.cityName,\n population: rule.basePopulation + index * 3,\n wealthIndex: rule.wealthIndex,\n satisfaction: rule.satisfaction,\n influence: rule.influence,\n demandIndex: rule.demandBias,\n supplyIndex: rule.supplyBias,\n pressureScore: 50,\n status: \"watch\",\n statusLabel: STATUS_LABELS.watch,\n goodsIds: Array.from(rule.goodsIds),\n marketCategories: Array.from(rule.marketCategories),\n tags: Array.from(rule.tags),\n createdTurn: currentTurn(worldState),\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState);\n}\n\nfunction normalizeSemiNamedActor(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const actorId = cleanId(raw.actorId || raw.id, \"\");\n const cohortId = cleanId(raw.cohortId, \"\");\n if (!actorId || !cohortId) return null;\n return {\n actorId,\n actorTier: \"semi_named\",\n cohortId,\n displayName: cleanText(raw.displayName || raw.name, \"某行头\", 80),\n roleLabel: cleanText(raw.roleLabel, \"行会代表\", 60),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n status: cleanId(raw.status, \"representing\"),\n pressureScore: clampNumber(raw.pressureScore, 0, 100, 50),\n influence: clampNumber(raw.influence, 0, 100, 50),\n trustProxy: clampNumber(raw.trustProxy, 0, 100, 45),\n upgradeEligible: raw.upgradeEligible === true,\n publicSummary: cleanText(raw.publicSummary, \"半具名代表已入公开案卷,可在事件需要时升格为具名 NPC。\", 200),\n tags: uniqueCleanList(raw.tags, 8, 40),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeNamedPromotion(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const promotionId = cleanId(raw.promotionId || raw.id, \"\");\n const sourceActorId = cleanId(raw.sourceActorId, \"\");\n const npcId = cleanId(raw.npcId || raw.targetNpcId, \"\");\n if (!promotionId || !sourceActorId || !npcId) return null;\n return {\n promotionId,\n sourceActorId,\n npcId,\n displayName: cleanText(raw.displayName, \"行会人物\", 80),\n cohortId: cleanId(raw.cohortId, \"\"),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n status: cleanId(raw.status, \"promoted\"),\n publicSummary: cleanText(raw.publicSummary, \"半具名代表因事件压力升格为具名 NPC。\", 200),\n tags: uniqueCleanList(raw.tags, 8, 40),\n promotedTurn: clampNumber(raw.promotedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeMarketReaction(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const reactionId = cleanId(raw.reactionId || raw.id, \"\");\n if (!reactionId) return null;\n return {\n reactionId,\n cohortId: cleanId(raw.cohortId, \"\"),\n actorTier: cleanText(raw.actorTier, \"cohort\", 40),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n goodsIds: uniqueGoodsIds(raw.goodsIds, 12),\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n pressureDelta: clampNumber(raw.pressureDelta, -NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 0),\n demandDelta: clampNumber(raw.demandDelta, -20, 20, 0),\n supplyDelta: clampNumber(raw.supplyDelta, -20, 20, 0),\n status: cleanId(raw.status, \"recorded\"),\n reasonLabel: cleanText(raw.reasonLabel, \"群体市面反应\", 80),\n publicSummary: cleanText(raw.publicSummary, \"群体反应已登记,仅影响市场压力,不直接成交。\", 200),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeShadowActor(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const shadowId = cleanId(raw.shadowId || raw.id, \"\");\n if (!shadowId) return null;\n return {\n shadowId,\n actorTier: \"market_shadow\",\n label: cleanText(raw.label, \"市场影子\", 80),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n estimatedScale: cleanText(raw.estimatedScale, \"city-scale\", 40),\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n pressureDelta: clampNumber(raw.pressureDelta, -NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 0),\n publicSummary: cleanText(raw.publicSummary, \"市场影子 actor 不逐个存档,只保留聚合压力。\", 180),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeLedger(raw = {}, worldState = {}, options = {}) {\n const source = isPlainObject(raw) ? raw : {};\n const cohorts = (Array.isArray(source.cohorts) ? source.cohorts : [])\n .map((row) => normalizeCohort(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxCohorts);\n const semiNamedActors = (Array.isArray(source.semiNamedActors) ? source.semiNamedActors : [])\n .map((row) => normalizeSemiNamedActor(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxSemiNamedActors);\n const namedPromotions = (Array.isArray(source.namedPromotions) ? source.namedPromotions : [])\n .map((row) => normalizeNamedPromotion(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxNamedPromotions);\n const marketReactions = (Array.isArray(source.marketReactions) ? source.marketReactions : [])\n .map((row) => normalizeMarketReaction(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxMarketReactions);\n const shadowActors = (Array.isArray(source.shadowActors) ? source.shadowActors : [])\n .map((row) => normalizeShadowActor(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxShadowActors);\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n generatedAtTurn: clampNumber(source.generatedAtTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastTickTurn: source.lastTickTurn === null || source.lastTickTurn === undefined\n ? null\n : clampNumber(source.lastTickTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastMonthlyPeriodKey: cleanText(source.lastMonthlyPeriodKey, \"\", 32),\n cohorts: cohorts.length ? cohorts : seedCohorts(worldState, options),\n semiNamedActors,\n namedPromotions,\n marketReactions,\n shadowActors,\n recentEvents: uniqueCleanList(source.recentEvents, NPC_COHORT_CONFIG.maxEvents, 180),\n lastOutcome: isPlainObject(source.lastOutcome) ? cloneJson(source.lastOutcome) : null\n };\n}\n\nfunction seedCohorts(worldState = {}, options = {}) {\n const cities = cityRowsFrom(worldState, options);\n return NPC_COHORT_SEED_RULES\n .map((rule, index) => buildCohortFromRule(rule, cities[index % cities.length], index, worldState))\n .filter(Boolean)\n .slice(0, NPC_COHORT_CONFIG.maxCohorts);\n}\n\nfunction createInitialNpcCohortLedger(worldState = {}, options = {}) {\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n lastTickTurn: null,\n lastMonthlyPeriodKey: \"\",\n cohorts: seedCohorts(worldState, options),\n semiNamedActors: [],\n namedPromotions: [],\n marketReactions: [],\n shadowActors: [],\n recentEvents: [],\n lastOutcome: null\n };\n}\n\nfunction ensureNpcCohortLedgerState(worldState = {}, options = {}) {\n if (\n !isPlainObject(worldState.npcCohortLedger) ||\n worldState.npcCohortLedger.schemaVersion !== NPC_COHORT_SCHEMA_VERSION\n ) {\n worldState.npcCohortLedger = createInitialNpcCohortLedger(worldState, options);\n } else {\n worldState.npcCohortLedger = normalizeLedger(worldState.npcCohortLedger, worldState, options);\n }\n return worldState.npcCohortLedger;\n}\n\nfunction buildNpcCohortLedgerSnapshot(worldState = {}, options = {}) {\n if (\n isPlainObject(worldState.npcCohortLedger) &&\n worldState.npcCohortLedger.schemaVersion === NPC_COHORT_SCHEMA_VERSION\n ) {\n return normalizeLedger(worldState.npcCohortLedger, worldState, options);\n }\n return createInitialNpcCohortLedger(worldState, options);\n}\n\nfunction routeRiskFromView(tradeRouteView = {}) {\n const rows = [\n ...(Array.isArray(tradeRouteView.routeRows) ? tradeRouteView.routeRows : []),\n ...(Array.isArray(tradeRouteView.caravanRows) ? tradeRouteView.caravanRows : []),\n ...(Array.isArray(tradeRouteView.routePlans) ? tradeRouteView.routePlans : [])\n ];\n if (!rows.length) return 0;\n return Math.round(rows.reduce((sum, row) => sum + clampNumber(row.riskScore, 0, 100, 40), 0) / rows.length);\n}\n\nfunction bankingRiskFromView(bankingView = {}) {\n const riskRows = Array.isArray(bankingView.riskEventRows) ? bankingView.riskEventRows : [];\n const branchRows = Array.isArray(bankingView.branchRows) ? bankingView.branchRows : [];\n const badRisk = riskRows.length ? 70 : 0;\n const branchRisk = branchRows.length\n ? Math.round(branchRows.reduce((sum, row) => sum + clampNumber(row.runRisk, 0, 100, 35), 0) / branchRows.length)\n : 0;\n return Math.max(badRisk, branchRisk);\n}\n\nfunction laborPressureFromView(playerEnterpriseView = {}) {\n const laborRows = Array.isArray(playerEnterpriseView.laborRows) ? playerEnterpriseView.laborRows : [];\n if (!laborRows.length) return 0;\n const morale = Math.round(laborRows.reduce((sum, row) => sum + clampNumber(row.morale, 0, 100, 50), 0) / laborRows.length);\n return clampNumber(100 - morale, 0, 100, 0);\n}\n\nfunction pressureInputs(worldState = {}, options = {}) {\n return {\n publicOrder: clampNumber(worldState.publicOrder, 0, 100, 70),\n taxRate: clampNumber(worldState.taxRate, 0, 100, 30),\n corruption: clampNumber(worldState.corruption, 0, 100, 60),\n borderThreat: clampNumber(worldState.borderThreat, 0, 100, 40),\n routeRisk: routeRiskFromView(options.tradeRouteView),\n bankingRisk: bankingRiskFromView(options.bankingView),\n laborPressure: laborPressureFromView(options.playerEnterpriseView)\n };\n}\n\nfunction updatedCohort(cohort, city, inputs, worldState = {}) {\n const basePressure = clampNumber(\n 24 +\n (100 - inputs.publicOrder) * 0.18 +\n inputs.taxRate * 0.16 +\n inputs.corruption * 0.15 +\n inputs.borderThreat * 0.08 +\n inputs.routeRisk * 0.12 +\n inputs.bankingRisk * 0.1 +\n (city?.marketPressure || 45) * 0.22 +\n (100 - cohort.satisfaction) * 0.2,\n 0,\n 100,\n 50\n );\n const laborBonus = cohort.cohortType === \"labor_cohort\" ? inputs.laborPressure * 0.22 : 0;\n const financialBonus = cohort.marketCategories.includes(\"financial\") ? inputs.bankingRisk * 0.18 : 0;\n const pressureScore = clampNumber(basePressure + laborBonus + financialBonus, 0, 100, cohort.pressureScore);\n const status = statusForPressure(pressureScore);\n const satisfactionDelta = pressureScore >= 75 ? -5 : pressureScore >= 60 ? -2 : pressureScore <= 35 ? 2 : 0;\n const wealthDelta = pressureScore >= 78 && cohort.cohortType === \"merchant_guild\" ? 2 : pressureScore >= 78 ? -2 : pressureScore <= 34 ? 1 : 0;\n const demandDelta = pressureScore >= 70 ? 4 : pressureScore <= 35 ? -2 : 0;\n const supplyDelta = pressureScore >= 82 ? -5 : pressureScore >= 68 ? -2 : pressureScore <= 34 ? 2 : 0;\n const next = {\n ...cohort,\n homeCityName: city?.cityName || cohort.homeCityName,\n pressureScore,\n status,\n statusLabel: STATUS_LABELS[status],\n satisfaction: clampNumber(cohort.satisfaction + satisfactionDelta, 0, 100, cohort.satisfaction),\n wealthIndex: clampNumber(cohort.wealthIndex + wealthDelta, 0, 100, cohort.wealthIndex),\n demandIndex: clampNumber(cohort.demandIndex + demandDelta, 0, 100, cohort.demandIndex),\n supplyIndex: clampNumber(cohort.supplyIndex + supplyDelta, 0, 100, cohort.supplyIndex),\n lastUpdatedTurn: currentTurn(worldState)\n };\n next.publicSummary = buildCohortPublicSummary(next);\n return next;\n}\n\nfunction marketReactionForCohort(cohort, worldState = {}) {\n if (cohort.pressureScore < 52 && cohort.pressureScore > 34) return null;\n const pressureDelta = cohort.pressureScore >= 70\n ? clampNumber((cohort.pressureScore - 58) / 4, 1, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 1)\n : clampNumber((cohort.pressureScore - 45) / 5, -6, -1, -1);\n const reasonLabel = cohort.pressureScore >= 70\n ? `${cohort.displayName}抬价观望`\n : `${cohort.displayName}供给转宽`;\n return normalizeMarketReaction({\n reactionId: `cohort-reaction:${cohort.cohortId}:${currentTurn(worldState)}`,\n cohortId: cohort.cohortId,\n actorTier: \"cohort\",\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n goodsIds: cohort.goodsIds,\n marketCategories: cohort.marketCategories,\n pressureDelta,\n demandDelta: cohort.demandIndex - 50,\n supplyDelta: cohort.supplyIndex - 50,\n status: cohort.status,\n reasonLabel,\n publicSummary: `${cohort.homeCityName}${cohort.displayName}${cohort.statusLabel},对${goodsLabels(cohort.goodsIds).join(\"、\") || \"市面\"}形成${pressureDelta >= 0 ? \"上行\" : \"缓和\"}压力;成交、扣库和税费仍由服务器 resolver 裁决。`,\n createdTurn: currentTurn(worldState)\n }, worldState);\n}\n\nfunction shadowActorsFor(worldState = {}, cities = [], inputs = {}) {\n const city = cities[0] || cityRowsFrom(worldState)[0];\n return NPC_COHORT_SHADOW_RULES.map((rule, index) => normalizeShadowActor({\n shadowId: `${rule.shadowId}:${cleanId(city.cityId, \"home\")}`,\n label: rule.label,\n cityId: city.cityId,\n cityName: city.cityName,\n estimatedScale: index === 0 ? \"household-scale\" : \"route-scale\",\n marketCategories: Array.from(rule.marketCategories),\n pressureDelta: clampNumber((100 - inputs.publicOrder + inputs.taxRate + inputs.routeRisk) / 30 - 2, -4, 5, 0),\n publicSummary: rule.publicSummary,\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState)).filter(Boolean).slice(0, NPC_COHORT_CONFIG.maxShadowActors);\n}\n\nfunction semiActorName(cohort = {}) {\n const prefix = SEMI_NAME_PREFIXES[deterministicIndex(cohort.cohortId, SEMI_NAME_PREFIXES.length)];\n return `${prefix}${cohort.representativeTitle}`;\n}\n\nfunction createOrUpdateSemiNamedActor(ledger, cohort, worldState = {}) {\n const actorId = `semi-npc:${cohort.cohortId}`;\n const existing = ledger.semiNamedActors.find((actor) => actor.actorId === actorId);\n const publicSummary = `${cohort.displayName}因${cohort.statusLabel}推出${cohort.representativeTitle}出面,当前仍是半具名代表;只有持续卷入事件才升格具名 NPC。`;\n const next = normalizeSemiNamedActor({\n ...(existing || {}),\n actorId,\n cohortId: cohort.cohortId,\n displayName: existing?.displayName || semiActorName(cohort),\n roleLabel: cohort.representativeTitle,\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n pressureScore: cohort.pressureScore,\n influence: cohort.influence,\n trustProxy: clampNumber(50 + cohort.satisfaction / 4 - cohort.pressureScore / 5, 0, 100, 45),\n upgradeEligible: cohort.pressureScore >= NPC_COHORT_CONFIG.namedPressureThreshold,\n publicSummary,\n tags: cohort.tags,\n createdTurn: existing?.createdTurn ?? currentTurn(worldState),\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState);\n if (existing) {\n Object.assign(existing, next);\n return { actor: existing, created: false };\n }\n ledger.semiNamedActors.push(next);\n ledger.semiNamedActors = ledger.semiNamedActors.slice(-NPC_COHORT_CONFIG.maxSemiNamedActors);\n return { actor: next, created: true };\n}\n\nfunction ensureNamedNpcForActor(worldState = {}, actor = {}, cohort = {}) {\n const roster = ensureNpcRoster(worldState);\n const npcId = `npc:s93:e9:${cleanId(actor.actorId, \"actor\").replace(/:/g, \"-\")}`;\n if ((roster.npcs || []).some((npc) => npc.npcId === npcId)) return { npcId, created: false };\n const relationshipTrust = clampNumber(actor.trustProxy, 0, 100, 45);\n roster.npcs.push({\n npcId,\n sourceRef: `npcCohortView:${actor.actorId}`,\n displayName: actor.displayName,\n tier: \"active\",\n roleTags: uniqueCleanList([\"economic_actor\", cohort.cohortType, ...cohort.tags], 12, 40),\n stageTags: uniqueCleanList([\"s93_e9\", \"npc_cohort\", cohort.status], 12, 40),\n portraitRef: assignPortraitRef({ npcId, tier: \"active\" }, `${worldState.sessionId || \"s93-e9\"}:${actor.actorId}`),\n publicProfile: {\n title: actor.roleLabel,\n origin: actor.cityName,\n posting: `${actor.cityName}${cohort.displayName}`,\n summary: actor.publicSummary,\n visibleAbilities: uniqueCleanList([\"行会交涉\", \"市面消息\", \"议价\"], 6, 40)\n },\n relationship: {\n closeness: 0,\n trust: relationshipTrust,\n awe: clampNumber(cohort.influence / 2, 0, 100, 20),\n hostility: clampNumber(cohort.pressureScore >= 86 ? 18 : cohort.pressureScore >= 74 ? 8 : 2, 0, 100, 0),\n favorsOwed: 0,\n labels: uniqueCleanList([\"商帮代表\", cohort.statusLabel], 6, 40)\n },\n inventoryRefs: [],\n assetRefs: [],\n resourceAccountRefs: [],\n availableInteractions: [\"talk\", \"inquire\", \"trade\", \"request\", \"delegate\"],\n socialProfile: {\n adult: true,\n marriageStatus: \"unknown\",\n ritualStatus: \"economic_representative\",\n kinshipBlocked: false,\n publicNote: \"此人由群体事件升格,关系和交易仍由服务器裁决。\"\n },\n hiddenDossier: {\n motives: [],\n trueAssets: [],\n secretRelationships: [],\n unrevealedTasks: []\n },\n privateSignalTags: []\n });\n return { npcId, created: true };\n}\n\nfunction maybePromoteNamed(ledger, worldState, actor, cohort) {\n if (!actor.upgradeEligible) return null;\n const existing = ledger.namedPromotions.find((row) => row.sourceActorId === actor.actorId);\n if (existing) return null;\n const named = ensureNamedNpcForActor(worldState, actor, cohort);\n if (!named.created) return null;\n const promotion = normalizeNamedPromotion({\n promotionId: `named-promotion:${actor.actorId}:${currentTurn(worldState)}`,\n sourceActorId: actor.actorId,\n npcId: named.npcId,\n displayName: actor.displayName,\n cohortId: cohort.cohortId,\n cityId: actor.cityId,\n cityName: actor.cityName,\n status: \"promoted\",\n publicSummary: `${actor.displayName}因${cohort.displayName}${cohort.statusLabel}升格为具名 NPC,可进入交涉、委派与交易链路;资源和关系仍由服务器裁决。`,\n tags: actor.tags,\n promotedTurn: currentTurn(worldState)\n }, worldState);\n ledger.namedPromotions.push(promotion);\n ledger.namedPromotions = ledger.namedPromotions.slice(-NPC_COHORT_CONFIG.maxNamedPromotions);\n return promotion;\n}\n\nfunction emptyFeedback(cadence = \"ten_day\") {\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n cadence,\n summary: \"\",\n events: [],\n attributeChanges: [],\n outcome: {\n cohortsUpdated: 0,\n marketReactions: 0,\n shadowActors: 0,\n semiNamedPromotions: 0,\n namedPromotions: 0\n }\n };\n}\n\nfunction runNpcCohortTickStep(worldState = {}, options = {}) {\n if (!isPlainObject(worldState)) return emptyFeedback();\n const cadence = options.worldTick?.completedMonth || options.forceMonthly ? \"monthly\" : \"ten_day\";\n const result = emptyFeedback(cadence);\n const ledger = ensureNpcCohortLedgerState(worldState, options);\n if (cadence !== \"monthly\") {\n ledger.generatedAtTurn = currentTurn(worldState);\n ledger.lastTickTurn = currentTurn(worldState);\n worldState.npcCohortLedger = ledger;\n result.summary = \"NPC 群体账旬更:本旬只保留既有商帮、工群与市场影子摘要,升格和市场反应在月结处理。\";\n return result;\n }\n\n const cities = cityRowsFrom(worldState, options);\n const cityMap = cityById(cities);\n const inputs = pressureInputs(worldState, options);\n const events = [];\n let semiNamedPromotions = 0;\n let namedPromotions = 0;\n const reactions = [];\n\n ledger.cohorts = ledger.cohorts.map((cohort) => {\n const city = cityMap.get(cohort.homeCityId) || cities[deterministicIndex(cohort.cohortId, cities.length)];\n const next = updatedCohort(cohort, city, inputs, worldState);\n const reaction = marketReactionForCohort(next, worldState);\n if (reaction) reactions.push(reaction);\n if (next.pressureScore >= NPC_COHORT_CONFIG.semiNamedPressureThreshold && semiNamedPromotions < NPC_COHORT_CONFIG.maxPromotionsPerTick) {\n const promoted = createOrUpdateSemiNamedActor(ledger, next, worldState);\n if (promoted.created) {\n semiNamedPromotions += 1;\n events.push(`群体升格:${promoted.actor.displayName}代表${next.displayName}入公开案卷。`);\n }\n const named = maybePromoteNamed(ledger, worldState, promoted.actor, next);\n if (named) {\n namedPromotions += 1;\n events.push(`具名升格:${named.displayName}已成为可交涉 NPC。`);\n }\n }\n return next;\n });\n\n ledger.marketReactions = [\n ...ledger.marketReactions,\n ...reactions\n ].slice(-NPC_COHORT_CONFIG.maxMarketReactions);\n ledger.shadowActors = shadowActorsFor(worldState, cities, inputs);\n ledger.generatedAtTurn = currentTurn(worldState);\n ledger.lastTickTurn = currentTurn(worldState);\n ledger.lastMonthlyPeriodKey = `${worldState.year || 1644}-${String(worldState.month || 1).padStart(2, \"0\")}`;\n result.outcome = {\n cohortsUpdated: ledger.cohorts.length,\n marketReactions: reactions.length,\n shadowActors: ledger.shadowActors.length,\n semiNamedPromotions,\n namedPromotions\n };\n if (reactions.length) {\n events.push(`群体市面:${reactions.length}条商帮、工群或影子 actor 反应写入市场压力。`);\n }\n result.events = uniqueCleanList(events, NPC_COHORT_CONFIG.maxEventsPerTick, 180);\n result.summary = `NPC 群体月结:更新${ledger.cohorts.length}个 cohort,生成${reactions.length}条市场反应,半具名${semiNamedPromotions}人,具名升格${namedPromotions}人。`;\n ledger.recentEvents = uniqueCleanList([\n ...ledger.recentEvents,\n ...result.events\n ], NPC_COHORT_CONFIG.maxEvents, 180);\n ledger.lastOutcome = cloneJson(result.outcome);\n worldState.npcCohortLedger = ledger;\n return result;\n}\n\nfunction publicCohort(row = {}) {\n return {\n cohortId: row.cohortId,\n actorTier: \"cohort\",\n cohortType: row.cohortType,\n displayName: row.displayName,\n representativeTitle: row.representativeTitle,\n homeCityId: row.homeCityId,\n homeCityName: row.homeCityName,\n population: row.population,\n wealthIndex: row.wealthIndex,\n satisfaction: row.satisfaction,\n influence: row.influence,\n demandIndex: row.demandIndex,\n supplyIndex: row.supplyIndex,\n pressureScore: row.pressureScore,\n status: row.status,\n statusLabel: row.statusLabel,\n goodsIds: row.goodsIds,\n goodsLabels: goodsLabels(row.goodsIds),\n marketCategories: row.marketCategories,\n tags: row.tags,\n publicSummary: row.publicSummary,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction publicSemiNamed(row = {}) {\n return {\n actorId: row.actorId,\n actorTier: \"semi_named\",\n cohortId: row.cohortId,\n displayName: row.displayName,\n roleLabel: row.roleLabel,\n cityId: row.cityId,\n cityName: row.cityName,\n pressureScore: row.pressureScore,\n influence: row.influence,\n trustProxy: row.trustProxy,\n upgradeEligible: row.upgradeEligible,\n publicSummary: row.publicSummary,\n tags: row.tags,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction publicNamedPromotion(row = {}) {\n return {\n promotionId: row.promotionId,\n sourceActorId: row.sourceActorId,\n npcId: row.npcId,\n displayName: row.displayName,\n cohortId: row.cohortId,\n cityName: row.cityName,\n status: row.status,\n publicSummary: row.publicSummary,\n tags: row.tags,\n promotedTurn: row.promotedTurn\n };\n}\n\nfunction publicMarketReaction(row = {}) {\n return {\n reactionId: row.reactionId,\n cohortId: row.cohortId,\n actorTier: row.actorTier,\n cityId: row.cityId,\n cityName: row.cityName,\n goodsIds: row.goodsIds,\n goodsLabels: goodsLabels(row.goodsIds),\n marketCategories: row.marketCategories,\n pressureDelta: row.pressureDelta,\n demandDelta: row.demandDelta,\n supplyDelta: row.supplyDelta,\n status: row.status,\n reasonLabel: row.reasonLabel,\n publicSummary: row.publicSummary,\n createdTurn: row.createdTurn\n };\n}\n\nfunction publicShadowActor(row = {}) {\n return {\n shadowId: row.shadowId,\n actorTier: \"market_shadow\",\n label: row.label,\n cityId: row.cityId,\n cityName: row.cityName,\n estimatedScale: row.estimatedScale,\n marketCategories: row.marketCategories,\n pressureDelta: row.pressureDelta,\n publicSummary: row.publicSummary,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction buildNpcCohortView(worldState = {}, options = {}) {\n const ledger = options.persistLedger === true\n ? ensureNpcCohortLedgerState(worldState, options)\n : buildNpcCohortLedgerSnapshot(worldState, options);\n const cohortRows = ledger.cohorts.slice(0, NPC_COHORT_CONFIG.maxCohortRowsInView).map(publicCohort);\n const semiNamedRows = ledger.semiNamedActors.slice(-NPC_COHORT_CONFIG.maxSemiNamedRowsInView).map(publicSemiNamed);\n const namedPromotionRows = ledger.namedPromotions.slice(-NPC_COHORT_CONFIG.maxNamedPromotionRowsInView).map(publicNamedPromotion);\n const marketReactionRows = ledger.marketReactions.slice(-NPC_COHORT_CONFIG.maxMarketReactionRowsInView).map(publicMarketReaction);\n const shadowActorRows = ledger.shadowActors.slice(0, NPC_COHORT_CONFIG.maxShadowActorRowsInView).map(publicShadowActor);\n return {\n schemaVersion: NPC_COHORT_VIEW_SCHEMA_VERSION,\n source: \"server_npc_cohort_projection\",\n generatedAtTurn: ledger.generatedAtTurn,\n dateLabel: formatYearMonthPeriod(worldState),\n lastTickTurn: ledger.lastTickTurn,\n lastMonthlyPeriodKey: ledger.lastMonthlyPeriodKey,\n cohortRows,\n semiNamedRows,\n namedPromotionRows,\n marketReactionRows,\n shadowActorRows,\n recentEvents: uniqueCleanList(ledger.recentEvents, NPC_COHORT_CONFIG.maxEvents, 180),\n lastOutcome: isPlainObject(ledger.lastOutcome) ? cloneJson(ledger.lastOutcome) : null,\n summary: cohortRows.length\n ? `已跟踪${cohortRows.length}个商帮、工群和服务 cohort,${semiNamedRows.length}个半具名代表,${marketReactionRows.length}条市场反应。`\n : \"暂无 NPC 群体经济摘要。\",\n aiReadScope: {\n readableFields: [\n \"cohortRows.publicSummary\",\n \"semiNamedRows.publicSummary\",\n \"namedPromotionRows.publicSummary\",\n \"marketReactionRows.publicSummary\",\n \"shadowActorRows.publicSummary\"\n ],\n actorIntelligence: \"AI 只能读取公开群体摘要、代表身份、市场反应和升格结果;不得读取原始账本、隐藏动机、真实资产、个人秘密或本地文件。\"\n },\n toolPermissions: \"AI 可建议交涉、安抚、调查、政策草稿或剧情解释;不得直接结算市价、银钱、库存、工钱、贷款、商路、NPC 关系或升降格。\",\n proposalBoundaries: [\n \"cohort、半具名代表与具名 NPC 的升降格由服务器月结和后续 resolver 裁决。\",\n \"市场影子 actor 不逐个存档,只影响供需压力摘要;成交、税费和处罚另走服务器 resolver。\"\n ],\n serverAdjudication: \"服务器按公共秩序、税率、腐败、商路、产业劳力与钱庄风险生成群体压力、市场反应和升格记录;浏览器和模型均不能写原始群体账本。\",\n safeguards: {\n browserReadonly: true,\n internalLedgerRedacted: true,\n marketShadowActorsAggregated: true,\n aiCannotPromoteNpc: true,\n aiCannotSettleMarket: true,\n serverOwnsCohortSimulation: true\n },\n caps: {\n maxCohorts: NPC_COHORT_CONFIG.maxCohorts,\n maxSemiNamedActors: NPC_COHORT_CONFIG.maxSemiNamedActors,\n maxMarketReactions: NPC_COHORT_CONFIG.maxMarketReactions\n }\n };\n}\n\nfunction buildNpcCohortSearchRows(npcCohortView = {}, options = {}) {\n const rows = [];\n for (const cohort of Array.isArray(npcCohortView.cohortRows) ? npcCohortView.cohortRows : []) {\n rows.push({\n sourceId: cohort.cohortId,\n title: `${cohort.homeCityName}${cohort.displayName}`,\n summary: cohort.publicSummary,\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n tags: uniqueCleanList([\"npc_cohort\", cohort.cohortType, cohort.statusLabel, ...(cohort.tags || [])], 10, 40)\n });\n }\n for (const actor of Array.isArray(npcCohortView.semiNamedRows) ? npcCohortView.semiNamedRows : []) {\n rows.push({\n sourceId: actor.actorId,\n title: `${actor.displayName}${actor.roleLabel}`,\n summary: actor.publicSummary,\n cityId: actor.cityId,\n cityName: actor.cityName,\n tags: uniqueCleanList([\"semi_named\", \"npc_cohort\", ...(actor.tags || [])], 10, 40)\n });\n }\n for (const reaction of Array.isArray(npcCohortView.marketReactionRows) ? npcCohortView.marketReactionRows : []) {\n rows.push({\n sourceId: reaction.reactionId,\n title: reaction.reasonLabel,\n summary: reaction.publicSummary,\n cityId: reaction.cityId,\n cityName: reaction.cityName,\n tags: uniqueCleanList([\"market_reaction\", ...(reaction.goodsLabels || []), ...(reaction.marketCategories || [])], 10, 40)\n });\n }\n return rows.filter((row) => row.sourceId && row.title).slice(0, options.limit || NPC_COHORT_CONFIG.maxSearchRows);\n}\n\nfunction summarizeNpcCohortsForPrompt(worldState = {}, options = {}) {\n const view = buildNpcCohortView(worldState, options);\n return {\n schemaVersion: NPC_COHORT_VIEW_SCHEMA_VERSION,\n dateLabel: view.dateLabel,\n cohortRows: view.cohortRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n cohortId: row.cohortId,\n displayName: row.displayName,\n homeCityName: row.homeCityName,\n pressureScore: row.pressureScore,\n statusLabel: row.statusLabel,\n publicSummary: row.publicSummary\n })),\n semiNamedRows: view.semiNamedRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n actorId: row.actorId,\n displayName: row.displayName,\n roleLabel: row.roleLabel,\n publicSummary: row.publicSummary,\n upgradeEligible: row.upgradeEligible\n })),\n marketReactionRows: view.marketReactionRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n reactionId: row.reactionId,\n reasonLabel: row.reasonLabel,\n pressureDelta: row.pressureDelta,\n publicSummary: row.publicSummary\n })),\n safeguards: view.safeguards,\n toolPermissions: view.toolPermissions\n };\n}\n\nfunction marketPressureAdjustmentForDefinition(worldState = {}, definition = {}) {\n const ledger = isPlainObject(worldState.npcCohortLedger) &&\n worldState.npcCohortLedger.schemaVersion === NPC_COHORT_SCHEMA_VERSION\n ? worldState.npcCohortLedger\n : null;\n if (!ledger) return 0;\n const goodsId = cleanId(definition.goodsId || definition.priceId, \"\");\n const category = cleanText(definition.marketCategory || definition.category, \"\", 40);\n let total = 0;\n for (const reaction of Array.isArray(ledger.marketReactions) ? ledger.marketReactions.slice(-NPC_COHORT_CONFIG.maxMarketReactions) : []) {\n const safe = normalizeMarketReaction(reaction, worldState);\n if (!safe) continue;\n const goodsMatch = goodsId && safe.goodsIds.includes(goodsId);\n const categoryMatch = category && safe.marketCategories.includes(category);\n if (!goodsMatch && !categoryMatch) continue;\n total += safe.pressureDelta;\n }\n for (const shadow of Array.isArray(ledger.shadowActors) ? ledger.shadowActors : []) {\n const safe = normalizeShadowActor(shadow, worldState);\n if (!safe || !category || !safe.marketCategories.includes(category)) continue;\n total += safe.pressureDelta;\n }\n return clampNumber(total, -NPC_COHORT_CONFIG.maxPressureAdjustment, NPC_COHORT_CONFIG.maxPressureAdjustment, 0);\n}\n\nmodule.exports = {\n buildNpcCohortLedgerSnapshot,\n buildNpcCohortSearchRows,\n buildNpcCohortView,\n createInitialNpcCohortLedger,\n ensureNpcCohortLedgerState,\n marketPressureAdjustmentForDefinition,\n runNpcCohortTickStep,\n summarizeNpcCohortsForPrompt\n};\n"}, "4fee03106db37fd9558811ef943db46aa28615a7": {"t": 1785753929.9176753, "n": 39404, "h": "df9a73233adac3ad", "c": "const { getGoodsDefinition } = require(\"./goodsCatalog\");\nconst { assignPortraitRef, ensureNpcRoster } = require(\"./npcRoster\");\nconst { formatYearMonthPeriod } = require(\"./time\");\nconst {\n NPC_COHORT_CONFIG,\n NPC_COHORT_SCHEMA_VERSION,\n NPC_COHORT_SEED_RULES,\n NPC_COHORT_SHADOW_RULES,\n NPC_COHORT_VIEW_SCHEMA_VERSION\n} = require(\"./npcCohortConfig\");\n\nconst SAFE_TEXT_PATTERN =\n /(SEALED_[A-Z0-9_]+|hidden[_ -]?(?:notes?|intent|dossier)|hiddenDossier|privateSignalTags|raw[_ -]?(?:provider|audit|table|ledger|prompt|state|proposal|row)|worldState|provider|proposal|prompt|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|sqlite|world_sessions|prompt_retrieval_index|event_archive_index|safe_search_(?:index|fts)|ai_change_proposals|event_log|\\b(?:npcCohortLedger|npcEconomyLedger|npcRoster|marketPriceLedger|cityMarketLedger|tradeRouteLedger|caravanLedger|economicContractLedger|industryLedger|bankingLedger|conflictOfInterestLedger|assetLedger|resourceLedger|inventoryLedger|delegatedTaskLedger)\\b|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt|\\/workspace)\\/[^\\s\"'<>]+)/i;\n\nconst STATUS_LABELS = Object.freeze({\n calm: \"平稳\",\n watch: \"观望\",\n pressured: \"承压\",\n mobilizing: \"聚众议价\",\n unrest: \"停工罢市风险\"\n});\n\nconst SEMI_NAME_PREFIXES = Object.freeze([\"赵\", \"钱\", \"孙\", \"李\", \"周\", \"吴\", \"郑\", \"王\", \"陆\", \"沈\", \"顾\", \"韩\"]);\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction normalizeScalar(value) {\n if (typeof value !== \"string\" && typeof value !== \"number\" && typeof value !== \"boolean\") return \"\";\n return String(value).replace(/\\s+/g, \" \").trim();\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = 160) {\n const text = normalizeScalar(value);\n if (!text || SAFE_TEXT_PATTERN.test(text)) return fallback;\n return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 120);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction clampNumber(value, min, max, fallback = min) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction clampDecimal(value, min, max, fallback = min, precision = 2) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n const factor = 10 ** precision;\n return Math.max(min, Math.min(max, Math.round(parsed * factor) / factor));\n}\n\nfunction uniqueCleanList(values = [], limit = 8, maxLength = 80) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction uniqueGoodsIds(values = [], limit = 12) {\n const result = [];\n const seen = new Set();\n for (const value of Array.isArray(values) ? values : []) {\n const id = cleanId(value, \"\");\n if (!id || seen.has(id) || !getGoodsDefinition(id)) continue;\n seen.add(id);\n result.push(id);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction cloneJson(value) {\n return JSON.parse(JSON.stringify(value ?? null));\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction deterministicIndex(seed, length) {\n if (!length) return 0;\n let total = 0;\n for (const char of String(seed || \"default\")) total += char.codePointAt(0) || 0;\n return total % length;\n}\n\nfunction rawGeographyCities(worldState = {}) {\n const raw = worldState.worldGeography;\n if (Array.isArray(raw?.cities)) return raw.cities;\n if (Array.isArray(raw?.seed?.cities)) return raw.seed.cities;\n return [];\n}\n\nfunction cityRowsFrom(worldState = {}, options = {}) {\n const cityEconomyRows = Array.isArray(options.cityEconomyView?.cityRows)\n ? options.cityEconomyView.cityRows\n : Array.isArray(options.cityEconomyView?.items)\n ? options.cityEconomyView.items\n : [];\n const cityRows = cityEconomyRows.length\n ? cityEconomyRows.map((city) => ({\n cityId: cleanId(city.cityId || city.id, \"\"),\n cityName: cleanText(city.cityName || city.name, \"\", 60),\n marketPressure: clampNumber(city.marketPressure, 0, 100, 50),\n prosperityIndex: clampNumber(city.prosperityIndex, 0, 100, 50),\n stockAdequacy: clampNumber(city.stockAdequacy, 0, 100, 50)\n }))\n : rawGeographyCities(worldState).map((city) => ({\n cityId: cleanId(city.id || city.cityId, \"\"),\n cityName: cleanText(city.name || city.cityName, \"\", 60),\n marketPressure: clampNumber(city.marketPriceStress || city.pressure, 0, 100, 45),\n prosperityIndex: clampNumber(city.taxBase || city.prosperityIndex, 0, 100, 50),\n stockAdequacy: clampNumber(city.grainStock || city.stockAdequacy, 0, 100, 50)\n }));\n const filtered = cityRows.filter((city) => city.cityId && city.cityName);\n if (filtered.length) return filtered;\n return [{\n cityId: cleanId(worldState.player?.locationId || \"home\", \"home\"),\n cityName: cleanText(worldState.player?.locationName || \"本地\", \"本地\", 60),\n marketPressure: 45,\n prosperityIndex: 50,\n stockAdequacy: 50\n }];\n}\n\nfunction cityById(rows = []) {\n return new Map(rows.map((row) => [row.cityId, row]));\n}\n\nfunction statusForPressure(pressure) {\n if (pressure >= 86) return \"unrest\";\n if (pressure >= 74) return \"mobilizing\";\n if (pressure >= 58) return \"pressured\";\n if (pressure >= 38) return \"watch\";\n return \"calm\";\n}\n\nfunction goodsLabels(goodsIds = []) {\n return goodsIds\n .map((goodsId) => getGoodsDefinition(goodsId)?.displayName || \"\")\n .filter(Boolean)\n .slice(0, 4);\n}\n\nfunction buildCohortPublicSummary(cohort) {\n const goods = goodsLabels(cohort.goodsIds).join(\"、\") || \"市面要货\";\n return `${cohort.homeCityName}${cohort.displayName}约${cohort.population}人户,关注${goods};满意${cohort.satisfaction},影响${cohort.influence},当前${cohort.statusLabel}。`;\n}\n\nfunction normalizeCohort(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const cohortId = cleanId(raw.cohortId || raw.id, \"\");\n if (!cohortId) return null;\n const cohortType = cleanId(raw.cohortType, \"market_cohort\");\n const goodsIds = uniqueGoodsIds(raw.goodsIds, 12);\n const status = cleanId(raw.status, \"watch\");\n const cohort = {\n cohortId,\n archetypeId: cleanId(raw.archetypeId, cohortId),\n actorTier: \"cohort\",\n cohortType,\n displayName: cleanText(raw.displayName || raw.name, \"市面群体\", 80),\n representativeTitle: cleanText(raw.representativeTitle, \"行头\", 60),\n homeCityId: cleanId(raw.homeCityId || raw.cityId, \"home\"),\n homeCityName: cleanText(raw.homeCityName || raw.cityName, \"本地\", 60),\n population: clampNumber(raw.population, 1, 1000000, 1),\n wealthIndex: clampNumber(raw.wealthIndex, 0, 100, 50),\n satisfaction: clampNumber(raw.satisfaction, 0, 100, 50),\n influence: clampNumber(raw.influence, 0, 100, 45),\n demandIndex: clampNumber(raw.demandIndex, 0, 100, 50),\n supplyIndex: clampNumber(raw.supplyIndex, 0, 100, 50),\n pressureScore: clampNumber(raw.pressureScore, 0, 100, 50),\n status,\n statusLabel: cleanText(raw.statusLabel, STATUS_LABELS[status] || \"已登记\", 40),\n goodsIds,\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n tags: uniqueCleanList(raw.tags, 8, 40),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n publicSummary: cleanText(raw.publicSummary, \"\", 220)\n };\n cohort.publicSummary = cohort.publicSummary || buildCohortPublicSummary(cohort);\n return cohort;\n}\n\nfunction buildCohortFromRule(rule, city, index, worldState = {}) {\n const cohortId = `cohort:${cleanId(city.cityId, \"home\")}:${rule.archetypeId}`;\n return normalizeCohort({\n cohortId,\n archetypeId: rule.archetypeId,\n cohortType: rule.cohortType,\n displayName: rule.displayName,\n representativeTitle: rule.representativeTitle,\n homeCityId: city.cityId,\n homeCityName: city.cityName,\n population: rule.basePopulation + index * 3,\n wealthIndex: rule.wealthIndex,\n satisfaction: rule.satisfaction,\n influence: rule.influence,\n demandIndex: rule.demandBias,\n supplyIndex: rule.supplyBias,\n pressureScore: 50,\n status: \"watch\",\n statusLabel: STATUS_LABELS.watch,\n goodsIds: Array.from(rule.goodsIds),\n marketCategories: Array.from(rule.marketCategories),\n tags: Array.from(rule.tags),\n createdTurn: currentTurn(worldState),\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState);\n}\n\nfunction normalizeSemiNamedActor(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const actorId = cleanId(raw.actorId || raw.id, \"\");\n const cohortId = cleanId(raw.cohortId, \"\");\n if (!actorId || !cohortId) return null;\n return {\n actorId,\n actorTier: \"semi_named\",\n cohortId,\n displayName: cleanText(raw.displayName || raw.name, \"某行头\", 80),\n roleLabel: cleanText(raw.roleLabel, \"行会代表\", 60),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n status: cleanId(raw.status, \"representing\"),\n pressureScore: clampNumber(raw.pressureScore, 0, 100, 50),\n influence: clampNumber(raw.influence, 0, 100, 50),\n trustProxy: clampNumber(raw.trustProxy, 0, 100, 45),\n upgradeEligible: raw.upgradeEligible === true,\n publicSummary: cleanText(raw.publicSummary, \"半具名代表已入公开案卷,可在事件需要时升格为具名 NPC。\", 200),\n tags: uniqueCleanList(raw.tags, 8, 40),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeNamedPromotion(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const promotionId = cleanId(raw.promotionId || raw.id, \"\");\n const sourceActorId = cleanId(raw.sourceActorId, \"\");\n const npcId = cleanId(raw.npcId || raw.targetNpcId, \"\");\n if (!promotionId || !sourceActorId || !npcId) return null;\n return {\n promotionId,\n sourceActorId,\n npcId,\n displayName: cleanText(raw.displayName, \"行会人物\", 80),\n cohortId: cleanId(raw.cohortId, \"\"),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n status: cleanId(raw.status, \"promoted\"),\n publicSummary: cleanText(raw.publicSummary, \"半具名代表因事件压力升格为具名 NPC。\", 200),\n tags: uniqueCleanList(raw.tags, 8, 40),\n promotedTurn: clampNumber(raw.promotedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeMarketReaction(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const reactionId = cleanId(raw.reactionId || raw.id, \"\");\n if (!reactionId) return null;\n return {\n reactionId,\n cohortId: cleanId(raw.cohortId, \"\"),\n actorTier: cleanText(raw.actorTier, \"cohort\", 40),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n goodsIds: uniqueGoodsIds(raw.goodsIds, 12),\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n pressureDelta: clampNumber(raw.pressureDelta, -NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 0),\n demandDelta: clampNumber(raw.demandDelta, -20, 20, 0),\n supplyDelta: clampNumber(raw.supplyDelta, -20, 20, 0),\n status: cleanId(raw.status, \"recorded\"),\n reasonLabel: cleanText(raw.reasonLabel, \"群体市面反应\", 80),\n publicSummary: cleanText(raw.publicSummary, \"群体反应已登记,仅影响市场压力,不直接成交。\", 200),\n createdTurn: clampNumber(raw.createdTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeShadowActor(raw = {}, worldState = {}) {\n if (!isPlainObject(raw)) return null;\n const shadowId = cleanId(raw.shadowId || raw.id, \"\");\n if (!shadowId) return null;\n return {\n shadowId,\n actorTier: \"market_shadow\",\n label: cleanText(raw.label, \"市场影子\", 80),\n cityId: cleanId(raw.cityId, \"home\"),\n cityName: cleanText(raw.cityName, \"本地\", 60),\n estimatedScale: cleanText(raw.estimatedScale, \"city-scale\", 40),\n marketCategories: uniqueCleanList(raw.marketCategories, 8, 40),\n pressureDelta: clampNumber(raw.pressureDelta, -NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 0),\n publicSummary: cleanText(raw.publicSummary, \"市场影子 actor 不逐个存档,只保留聚合压力。\", 180),\n lastUpdatedTurn: clampNumber(raw.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState))\n };\n}\n\nfunction normalizeLedger(raw = {}, worldState = {}, options = {}) {\n const source = isPlainObject(raw) ? raw : {};\n const cohorts = (Array.isArray(source.cohorts) ? source.cohorts : [])\n .map((row) => normalizeCohort(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxCohorts);\n const semiNamedActors = (Array.isArray(source.semiNamedActors) ? source.semiNamedActors : [])\n .map((row) => normalizeSemiNamedActor(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxSemiNamedActors);\n const namedPromotions = (Array.isArray(source.namedPromotions) ? source.namedPromotions : [])\n .map((row) => normalizeNamedPromotion(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxNamedPromotions);\n const marketReactions = (Array.isArray(source.marketReactions) ? source.marketReactions : [])\n .map((row) => normalizeMarketReaction(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxMarketReactions);\n const shadowActors = (Array.isArray(source.shadowActors) ? source.shadowActors : [])\n .map((row) => normalizeShadowActor(row, worldState))\n .filter(Boolean)\n .slice(-NPC_COHORT_CONFIG.maxShadowActors);\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n generatedAtTurn: clampNumber(source.generatedAtTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastTickTurn: source.lastTickTurn === null || source.lastTickTurn === undefined\n ? null\n : clampNumber(source.lastTickTurn, 0, Number.MAX_SAFE_INTEGER, currentTurn(worldState)),\n lastMonthlyPeriodKey: cleanText(source.lastMonthlyPeriodKey, \"\", 32),\n cohorts: cohorts.length ? cohorts : seedCohorts(worldState, options),\n semiNamedActors,\n namedPromotions,\n marketReactions,\n shadowActors,\n recentEvents: uniqueCleanList(source.recentEvents, NPC_COHORT_CONFIG.maxEvents, 180),\n lastOutcome: isPlainObject(source.lastOutcome) ? cloneJson(source.lastOutcome) : null\n };\n}\n\nfunction seedCohorts(worldState = {}, options = {}) {\n const cities = cityRowsFrom(worldState, options);\n return NPC_COHORT_SEED_RULES\n .map((rule, index) => buildCohortFromRule(rule, cities[index % cities.length], index, worldState))\n .filter(Boolean)\n .slice(0, NPC_COHORT_CONFIG.maxCohorts);\n}\n\nfunction createInitialNpcCohortLedger(worldState = {}, options = {}) {\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n generatedAtTurn: currentTurn(worldState),\n lastTickTurn: null,\n lastMonthlyPeriodKey: \"\",\n cohorts: seedCohorts(worldState, options),\n semiNamedActors: [],\n namedPromotions: [],\n marketReactions: [],\n shadowActors: [],\n recentEvents: [],\n lastOutcome: null\n };\n}\n\nfunction ensureNpcCohortLedgerState(worldState = {}, options = {}) {\n if (\n !isPlainObject(worldState.npcCohortLedger) ||\n worldState.npcCohortLedger.schemaVersion !== NPC_COHORT_SCHEMA_VERSION\n ) {\n worldState.npcCohortLedger = createInitialNpcCohortLedger(worldState, options);\n } else {\n worldState.npcCohortLedger = normalizeLedger(worldState.npcCohortLedger, worldState, options);\n }\n return worldState.npcCohortLedger;\n}\n\nfunction buildNpcCohortLedgerSnapshot(worldState = {}, options = {}) {\n if (\n isPlainObject(worldState.npcCohortLedger) &&\n worldState.npcCohortLedger.schemaVersion === NPC_COHORT_SCHEMA_VERSION\n ) {\n return normalizeLedger(worldState.npcCohortLedger, worldState, options);\n }\n return createInitialNpcCohortLedger(worldState, options);\n}\n\nfunction routeRiskFromView(tradeRouteView = {}) {\n const rows = [\n ...(Array.isArray(tradeRouteView.routeRows) ? tradeRouteView.routeRows : []),\n ...(Array.isArray(tradeRouteView.caravanRows) ? tradeRouteView.caravanRows : []),\n ...(Array.isArray(tradeRouteView.routePlans) ? tradeRouteView.routePlans : [])\n ];\n if (!rows.length) return 0;\n return Math.round(rows.reduce((sum, row) => sum + clampNumber(row.riskScore, 0, 100, 40), 0) / rows.length);\n}\n\nfunction bankingRiskFromView(bankingView = {}) {\n const riskRows = Array.isArray(bankingView.riskEventRows) ? bankingView.riskEventRows : [];\n const branchRows = Array.isArray(bankingView.branchRows) ? bankingView.branchRows : [];\n const badRisk = riskRows.length ? 70 : 0;\n const branchRisk = branchRows.length\n ? Math.round(branchRows.reduce((sum, row) => sum + clampNumber(row.runRisk, 0, 100, 35), 0) / branchRows.length)\n : 0;\n return Math.max(badRisk, branchRisk);\n}\n\nfunction laborPressureFromView(playerEnterpriseView = {}) {\n const laborRows = Array.isArray(playerEnterpriseView.laborRows) ? playerEnterpriseView.laborRows : [];\n if (!laborRows.length) return 0;\n const morale = Math.round(laborRows.reduce((sum, row) => sum + clampNumber(row.morale, 0, 100, 50), 0) / laborRows.length);\n return clampNumber(100 - morale, 0, 100, 0);\n}\n\nfunction pressureInputs(worldState = {}, options = {}) {\n return {\n publicOrder: clampNumber(worldState.publicOrder, 0, 100, 70),\n taxRate: clampNumber(worldState.taxRate, 0, 100, 30),\n corruption: clampNumber(worldState.corruption, 0, 100, 60),\n borderThreat: clampNumber(worldState.borderThreat, 0, 100, 40),\n routeRisk: routeRiskFromView(options.tradeRouteView),\n bankingRisk: bankingRiskFromView(options.bankingView),\n laborPressure: laborPressureFromView(options.playerEnterpriseView)\n };\n}\n\nfunction updatedCohort(cohort, city, inputs, worldState = {}) {\n const basePressure = clampNumber(\n 24 +\n (100 - inputs.publicOrder) * 0.18 +\n inputs.taxRate * 0.16 +\n inputs.corruption * 0.15 +\n inputs.borderThreat * 0.08 +\n inputs.routeRisk * 0.12 +\n inputs.bankingRisk * 0.1 +\n (city?.marketPressure || 45) * 0.22 +\n (100 - cohort.satisfaction) * 0.2,\n 0,\n 100,\n 50\n );\n const laborBonus = cohort.cohortType === \"labor_cohort\" ? inputs.laborPressure * 0.22 : 0;\n const financialBonus = cohort.marketCategories.includes(\"financial\") ? inputs.bankingRisk * 0.18 : 0;\n const pressureScore = clampNumber(basePressure + laborBonus + financialBonus, 0, 100, cohort.pressureScore);\n const status = statusForPressure(pressureScore);\n const satisfactionDelta = pressureScore >= 75 ? -5 : pressureScore >= 60 ? -2 : pressureScore <= 35 ? 2 : 0;\n const wealthDelta = pressureScore >= 78 && cohort.cohortType === \"merchant_guild\" ? 2 : pressureScore >= 78 ? -2 : pressureScore <= 34 ? 1 : 0;\n const demandDelta = pressureScore >= 70 ? 4 : pressureScore <= 35 ? -2 : 0;\n const supplyDelta = pressureScore >= 82 ? -5 : pressureScore >= 68 ? -2 : pressureScore <= 34 ? 2 : 0;\n const next = {\n ...cohort,\n homeCityName: city?.cityName || cohort.homeCityName,\n pressureScore,\n status,\n statusLabel: STATUS_LABELS[status],\n satisfaction: clampNumber(cohort.satisfaction + satisfactionDelta, 0, 100, cohort.satisfaction),\n wealthIndex: clampNumber(cohort.wealthIndex + wealthDelta, 0, 100, cohort.wealthIndex),\n demandIndex: clampNumber(cohort.demandIndex + demandDelta, 0, 100, cohort.demandIndex),\n supplyIndex: clampNumber(cohort.supplyIndex + supplyDelta, 0, 100, cohort.supplyIndex),\n lastUpdatedTurn: currentTurn(worldState)\n };\n next.publicSummary = buildCohortPublicSummary(next);\n return next;\n}\n\nfunction marketReactionForCohort(cohort, worldState = {}) {\n if (cohort.pressureScore < 52 && cohort.pressureScore > 34) return null;\n const pressureDelta = cohort.pressureScore >= 70\n ? clampNumber((cohort.pressureScore - 58) / 4, 1, NPC_COHORT_CONFIG.maxPressureDeltaPerReaction, 1)\n : clampNumber((cohort.pressureScore - 45) / 5, -6, -1, -1);\n const reasonLabel = cohort.pressureScore >= 70\n ? `${cohort.displayName}抬价观望`\n : `${cohort.displayName}供给转宽`;\n return normalizeMarketReaction({\n reactionId: `cohort-reaction:${cohort.cohortId}:${currentTurn(worldState)}`,\n cohortId: cohort.cohortId,\n actorTier: \"cohort\",\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n goodsIds: cohort.goodsIds,\n marketCategories: cohort.marketCategories,\n pressureDelta,\n demandDelta: cohort.demandIndex - 50,\n supplyDelta: cohort.supplyIndex - 50,\n status: cohort.status,\n reasonLabel,\n publicSummary: `${cohort.homeCityName}${cohort.displayName}${cohort.statusLabel},对${goodsLabels(cohort.goodsIds).join(\"、\") || \"市面\"}形成${pressureDelta >= 0 ? \"上行\" : \"缓和\"}压力;成交、扣库和税费仍由服务器 resolver 裁决。`,\n createdTurn: currentTurn(worldState)\n }, worldState);\n}\n\nfunction shadowActorsFor(worldState = {}, cities = [], inputs = {}) {\n const city = cities[0] || cityRowsFrom(worldState)[0];\n return NPC_COHORT_SHADOW_RULES.map((rule, index) => normalizeShadowActor({\n shadowId: `${rule.shadowId}:${cleanId(city.cityId, \"home\")}`,\n label: rule.label,\n cityId: city.cityId,\n cityName: city.cityName,\n estimatedScale: index === 0 ? \"household-scale\" : \"route-scale\",\n marketCategories: Array.from(rule.marketCategories),\n pressureDelta: clampNumber((100 - inputs.publicOrder + inputs.taxRate + inputs.routeRisk) / 30 - 2, -4, 5, 0),\n publicSummary: rule.publicSummary,\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState)).filter(Boolean).slice(0, NPC_COHORT_CONFIG.maxShadowActors);\n}\n\nfunction semiActorName(cohort = {}) {\n const prefix = SEMI_NAME_PREFIXES[deterministicIndex(cohort.cohortId, SEMI_NAME_PREFIXES.length)];\n return `${prefix}${cohort.representativeTitle}`;\n}\n\nfunction createOrUpdateSemiNamedActor(ledger, cohort, worldState = {}) {\n const actorId = `semi-npc:${cohort.cohortId}`;\n const existing = ledger.semiNamedActors.find((actor) => actor.actorId === actorId);\n const publicSummary = `${cohort.displayName}因${cohort.statusLabel}推出${cohort.representativeTitle}出面,当前仍是半具名代表;只有持续卷入事件才升格具名 NPC。`;\n const next = normalizeSemiNamedActor({\n ...(existing || {}),\n actorId,\n cohortId: cohort.cohortId,\n displayName: existing?.displayName || semiActorName(cohort),\n roleLabel: cohort.representativeTitle,\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n pressureScore: cohort.pressureScore,\n influence: cohort.influence,\n trustProxy: clampNumber(50 + cohort.satisfaction / 4 - cohort.pressureScore / 5, 0, 100, 45),\n upgradeEligible: cohort.pressureScore >= NPC_COHORT_CONFIG.namedPressureThreshold,\n publicSummary,\n tags: cohort.tags,\n createdTurn: existing?.createdTurn ?? currentTurn(worldState),\n lastUpdatedTurn: currentTurn(worldState)\n }, worldState);\n if (existing) {\n Object.assign(existing, next);\n return { actor: existing, created: false };\n }\n ledger.semiNamedActors.push(next);\n ledger.semiNamedActors = ledger.semiNamedActors.slice(-NPC_COHORT_CONFIG.maxSemiNamedActors);\n return { actor: next, created: true };\n}\n\nfunction ensureNamedNpcForActor(worldState = {}, actor = {}, cohort = {}) {\n const roster = ensureNpcRoster(worldState);\n const npcId = `npc:s93:e9:${cleanId(actor.actorId, \"actor\").replace(/:/g, \"-\")}`;\n if ((roster.npcs || []).some((npc) => npc.npcId === npcId)) return { npcId, created: false };\n const relationshipTrust = clampNumber(actor.trustProxy, 0, 100, 45);\n roster.npcs.push({\n npcId,\n sourceRef: `npcCohortView:${actor.actorId}`,\n displayName: actor.displayName,\n tier: \"active\",\n roleTags: uniqueCleanList([\"economic_actor\", cohort.cohortType, ...cohort.tags], 12, 40),\n stageTags: uniqueCleanList([\"s93_e9\", \"npc_cohort\", cohort.status], 12, 40),\n portraitRef: assignPortraitRef({ npcId, tier: \"active\" }, `${worldState.sessionId || \"s93-e9\"}:${actor.actorId}`),\n publicProfile: {\n title: actor.roleLabel,\n origin: actor.cityName,\n posting: `${actor.cityName}${cohort.displayName}`,\n summary: actor.publicSummary,\n visibleAbilities: uniqueCleanList([\"行会交涉\", \"市面消息\", \"议价\"], 6, 40)\n },\n relationship: {\n closeness: 0,\n trust: relationshipTrust,\n awe: clampNumber(cohort.influence / 2, 0, 100, 20),\n hostility: clampNumber(cohort.pressureScore >= 86 ? 18 : cohort.pressureScore >= 74 ? 8 : 2, 0, 100, 0),\n favorsOwed: 0,\n labels: uniqueCleanList([\"商帮代表\", cohort.statusLabel], 6, 40)\n },\n inventoryRefs: [],\n assetRefs: [],\n resourceAccountRefs: [],\n availableInteractions: [\"talk\", \"inquire\", \"trade\", \"request\", \"delegate\"],\n socialProfile: {\n adult: true,\n marriageStatus: \"unknown\",\n ritualStatus: \"economic_representative\",\n kinshipBlocked: false,\n publicNote: \"此人由群体事件升格,关系和交易仍由服务器裁决。\"\n },\n hiddenDossier: {\n motives: [],\n trueAssets: [],\n secretRelationships: [],\n unrevealedTasks: []\n },\n privateSignalTags: []\n });\n return { npcId, created: true };\n}\n\nfunction maybePromoteNamed(ledger, worldState, actor, cohort) {\n if (!actor.upgradeEligible) return null;\n const existing = ledger.namedPromotions.find((row) => row.sourceActorId === actor.actorId);\n if (existing) return null;\n const named = ensureNamedNpcForActor(worldState, actor, cohort);\n if (!named.created) return null;\n const promotion = normalizeNamedPromotion({\n promotionId: `named-promotion:${actor.actorId}:${currentTurn(worldState)}`,\n sourceActorId: actor.actorId,\n npcId: named.npcId,\n displayName: actor.displayName,\n cohortId: cohort.cohortId,\n cityId: actor.cityId,\n cityName: actor.cityName,\n status: \"promoted\",\n publicSummary: `${actor.displayName}因${cohort.displayName}${cohort.statusLabel}升格为具名 NPC,可进入交涉、委派与交易链路;资源和关系仍由服务器裁决。`,\n tags: actor.tags,\n promotedTurn: currentTurn(worldState)\n }, worldState);\n ledger.namedPromotions.push(promotion);\n ledger.namedPromotions = ledger.namedPromotions.slice(-NPC_COHORT_CONFIG.maxNamedPromotions);\n return promotion;\n}\n\nfunction emptyFeedback(cadence = \"ten_day\") {\n return {\n schemaVersion: NPC_COHORT_SCHEMA_VERSION,\n cadence,\n summary: \"\",\n events: [],\n attributeChanges: [],\n outcome: {\n cohortsUpdated: 0,\n marketReactions: 0,\n shadowActors: 0,\n semiNamedPromotions: 0,\n namedPromotions: 0\n }\n };\n}\n\nfunction runNpcCohortTickStep(worldState = {}, options = {}) {\n if (!isPlainObject(worldState)) return emptyFeedback();\n const cadence = options.worldTick?.completedMonth || options.forceMonthly ? \"monthly\" : \"ten_day\";\n const result = emptyFeedback(cadence);\n const ledger = ensureNpcCohortLedgerState(worldState, options);\n if (cadence !== \"monthly\") {\n ledger.generatedAtTurn = currentTurn(worldState);\n ledger.lastTickTurn = currentTurn(worldState);\n worldState.npcCohortLedger = ledger;\n result.summary = \"NPC 群体账旬更:本旬只保留既有商帮、工群与市场影子摘要,升格和市场反应在月结处理。\";\n return result;\n }\n\n const cities = cityRowsFrom(worldState, options);\n const cityMap = cityById(cities);\n const inputs = pressureInputs(worldState, options);\n const events = [];\n let semiNamedPromotions = 0;\n let namedPromotions = 0;\n const reactions = [];\n\n ledger.cohorts = ledger.cohorts.map((cohort) => {\n const city = cityMap.get(cohort.homeCityId) || cities[deterministicIndex(cohort.cohortId, cities.length)];\n const next = updatedCohort(cohort, city, inputs, worldState);\n const reaction = marketReactionForCohort(next, worldState);\n if (reaction) reactions.push(reaction);\n if (next.pressureScore >= NPC_COHORT_CONFIG.semiNamedPressureThreshold && semiNamedPromotions < NPC_COHORT_CONFIG.maxPromotionsPerTick) {\n const promoted = createOrUpdateSemiNamedActor(ledger, next, worldState);\n if (promoted.created) {\n semiNamedPromotions += 1;\n events.push(`群体升格:${promoted.actor.displayName}代表${next.displayName}入公开案卷。`);\n }\n const named = maybePromoteNamed(ledger, worldState, promoted.actor, next);\n if (named) {\n namedPromotions += 1;\n events.push(`具名升格:${named.displayName}已成为可交涉 NPC。`);\n }\n }\n return next;\n });\n\n ledger.marketReactions = [\n ...ledger.marketReactions,\n ...reactions\n ].slice(-NPC_COHORT_CONFIG.maxMarketReactions);\n ledger.shadowActors = shadowActorsFor(worldState, cities, inputs);\n ledger.generatedAtTurn = currentTurn(worldState);\n ledger.lastTickTurn = currentTurn(worldState);\n ledger.lastMonthlyPeriodKey = `${worldState.year || 1644}-${String(worldState.month || 1).padStart(2, \"0\")}`;\n result.outcome = {\n cohortsUpdated: ledger.cohorts.length,\n marketReactions: reactions.length,\n shadowActors: ledger.shadowActors.length,\n semiNamedPromotions,\n namedPromotions\n };\n if (reactions.length) {\n events.push(`群体市面:${reactions.length}条商帮、工群或影子 actor 反应写入市场压力。`);\n }\n result.events = uniqueCleanList(events, NPC_COHORT_CONFIG.maxEventsPerTick, 180);\n result.summary = `NPC 群体月结:更新${ledger.cohorts.length}个 cohort,生成${reactions.length}条市场反应,半具名${semiNamedPromotions}人,具名升格${namedPromotions}人。`;\n ledger.recentEvents = uniqueCleanList([\n ...ledger.recentEvents,\n ...result.events\n ], NPC_COHORT_CONFIG.maxEvents, 180);\n ledger.lastOutcome = cloneJson(result.outcome);\n worldState.npcCohortLedger = ledger;\n return result;\n}\n\nfunction publicCohort(row = {}) {\n return {\n cohortId: row.cohortId,\n actorTier: \"cohort\",\n cohortType: row.cohortType,\n displayName: row.displayName,\n representativeTitle: row.representativeTitle,\n homeCityId: row.homeCityId,\n homeCityName: row.homeCityName,\n population: row.population,\n wealthIndex: row.wealthIndex,\n satisfaction: row.satisfaction,\n influence: row.influence,\n demandIndex: row.demandIndex,\n supplyIndex: row.supplyIndex,\n pressureScore: row.pressureScore,\n status: row.status,\n statusLabel: row.statusLabel,\n goodsIds: row.goodsIds,\n goodsLabels: goodsLabels(row.goodsIds),\n marketCategories: row.marketCategories,\n tags: row.tags,\n publicSummary: row.publicSummary,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction publicSemiNamed(row = {}) {\n return {\n actorId: row.actorId,\n actorTier: \"semi_named\",\n cohortId: row.cohortId,\n displayName: row.displayName,\n roleLabel: row.roleLabel,\n cityId: row.cityId,\n cityName: row.cityName,\n pressureScore: row.pressureScore,\n influence: row.influence,\n trustProxy: row.trustProxy,\n upgradeEligible: row.upgradeEligible,\n publicSummary: row.publicSummary,\n tags: row.tags,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction publicNamedPromotion(row = {}) {\n return {\n promotionId: row.promotionId,\n sourceActorId: row.sourceActorId,\n npcId: row.npcId,\n displayName: row.displayName,\n cohortId: row.cohortId,\n cityName: row.cityName,\n status: row.status,\n publicSummary: row.publicSummary,\n tags: row.tags,\n promotedTurn: row.promotedTurn\n };\n}\n\nfunction publicMarketReaction(row = {}) {\n return {\n reactionId: row.reactionId,\n cohortId: row.cohortId,\n actorTier: row.actorTier,\n cityId: row.cityId,\n cityName: row.cityName,\n goodsIds: row.goodsIds,\n goodsLabels: goodsLabels(row.goodsIds),\n marketCategories: row.marketCategories,\n pressureDelta: row.pressureDelta,\n demandDelta: row.demandDelta,\n supplyDelta: row.supplyDelta,\n status: row.status,\n reasonLabel: row.reasonLabel,\n publicSummary: row.publicSummary,\n createdTurn: row.createdTurn\n };\n}\n\nfunction publicShadowActor(row = {}) {\n return {\n shadowId: row.shadowId,\n actorTier: \"market_shadow\",\n label: row.label,\n cityId: row.cityId,\n cityName: row.cityName,\n estimatedScale: row.estimatedScale,\n marketCategories: row.marketCategories,\n pressureDelta: row.pressureDelta,\n publicSummary: row.publicSummary,\n lastUpdatedTurn: row.lastUpdatedTurn\n };\n}\n\nfunction buildNpcCohortView(worldState = {}, options = {}) {\n const ledger = options.persistLedger === true\n ? ensureNpcCohortLedgerState(worldState, options)\n : buildNpcCohortLedgerSnapshot(worldState, options);\n const cohortRows = ledger.cohorts.slice(0, NPC_COHORT_CONFIG.maxCohortRowsInView).map(publicCohort);\n const semiNamedRows = ledger.semiNamedActors.slice(-NPC_COHORT_CONFIG.maxSemiNamedRowsInView).map(publicSemiNamed);\n const namedPromotionRows = ledger.namedPromotions.slice(-NPC_COHORT_CONFIG.maxNamedPromotionRowsInView).map(publicNamedPromotion);\n const marketReactionRows = ledger.marketReactions.slice(-NPC_COHORT_CONFIG.maxMarketReactionRowsInView).map(publicMarketReaction);\n const shadowActorRows = ledger.shadowActors.slice(0, NPC_COHORT_CONFIG.maxShadowActorRowsInView).map(publicShadowActor);\n return {\n schemaVersion: NPC_COHORT_VIEW_SCHEMA_VERSION,\n source: \"server_npc_cohort_projection\",\n generatedAtTurn: ledger.generatedAtTurn,\n dateLabel: formatYearMonthPeriod(worldState),\n lastTickTurn: ledger.lastTickTurn,\n lastMonthlyPeriodKey: ledger.lastMonthlyPeriodKey,\n cohortRows,\n semiNamedRows,\n namedPromotionRows,\n marketReactionRows,\n shadowActorRows,\n recentEvents: uniqueCleanList(ledger.recentEvents, NPC_COHORT_CONFIG.maxEvents, 180),\n lastOutcome: isPlainObject(ledger.lastOutcome) ? cloneJson(ledger.lastOutcome) : null,\n summary: cohortRows.length\n ? `已跟踪${cohortRows.length}个商帮、工群和服务 cohort,${semiNamedRows.length}个半具名代表,${marketReactionRows.length}条市场反应。`\n : \"暂无 NPC 群体经济摘要。\",\n aiReadScope: {\n readableFields: [\n \"cohortRows.publicSummary\",\n \"semiNamedRows.publicSummary\",\n \"namedPromotionRows.publicSummary\",\n \"marketReactionRows.publicSummary\",\n \"shadowActorRows.publicSummary\"\n ],\n actorIntelligence: \"AI 只能读取公开群体摘要、代表身份、市场反应和升格结果;不得读取原始账本、隐藏动机、真实资产、个人秘密或本地文件。\"\n },\n toolPermissions: \"AI 可建议交涉、安抚、调查、政策草稿或剧情解释;不得直接结算市价、银钱、库存、工钱、贷款、商路、NPC 关系或升降格。\",\n proposalBoundaries: [\n \"cohort、半具名代表与具名 NPC 的升降格由服务器月结和后续 resolver 裁决。\",\n \"市场影子 actor 不逐个存档,只影响供需压力摘要;成交、税费和处罚另走服务器 resolver。\"\n ],\n serverAdjudication: \"服务器按公共秩序、税率、腐败、商路、产业劳力与钱庄风险生成群体压力、市场反应和升格记录;浏览器和模型均不能写原始群体账本。\",\n safeguards: {\n browserReadonly: true,\n internalLedgerRedacted: true,\n marketShadowActorsAggregated: true,\n aiCannotPromoteNpc: true,\n aiCannotSettleMarket: true,\n serverOwnsCohortSimulation: true\n },\n caps: {\n maxCohorts: NPC_COHORT_CONFIG.maxCohorts,\n maxSemiNamedActors: NPC_COHORT_CONFIG.maxSemiNamedActors,\n maxMarketReactions: NPC_COHORT_CONFIG.maxMarketReactions\n }\n };\n}\n\nfunction buildNpcCohortSearchRows(npcCohortView = {}, options = {}) {\n const rows = [];\n for (const cohort of Array.isArray(npcCohortView.cohortRows) ? npcCohortView.cohortRows : []) {\n rows.push({\n sourceId: cohort.cohortId,\n title: `${cohort.homeCityName}${cohort.displayName}`,\n summary: cohort.publicSummary,\n cityId: cohort.homeCityId,\n cityName: cohort.homeCityName,\n tags: uniqueCleanList([\"npc_cohort\", cohort.cohortType, cohort.statusLabel, ...(cohort.tags || [])], 10, 40)\n });\n }\n for (const actor of Array.isArray(npcCohortView.semiNamedRows) ? npcCohortView.semiNamedRows : []) {\n rows.push({\n sourceId: actor.actorId,\n title: `${actor.displayName}${actor.roleLabel}`,\n summary: actor.publicSummary,\n cityId: actor.cityId,\n cityName: actor.cityName,\n tags: uniqueCleanList([\"semi_named\", \"npc_cohort\", ...(actor.tags || [])], 10, 40)\n });\n }\n for (const reaction of Array.isArray(npcCohortView.marketReactionRows) ? npcCohortView.marketReactionRows : []) {\n rows.push({\n sourceId: reaction.reactionId,\n title: reaction.reasonLabel,\n summary: reaction.publicSummary,\n cityId: reaction.cityId,\n cityName: reaction.cityName,\n tags: uniqueCleanList([\"market_reaction\", ...(reaction.goodsLabels || []), ...(reaction.marketCategories || [])], 10, 40)\n });\n }\n return rows.filter((row) => row.sourceId && row.title).slice(0, options.limit || NPC_COHORT_CONFIG.maxSearchRows);\n}\n\nfunction summarizeNpcCohortsForPrompt(worldState = {}, options = {}) {\n const view = buildNpcCohortView(worldState, options);\n return {\n schemaVersion: NPC_COHORT_VIEW_SCHEMA_VERSION,\n dateLabel: view.dateLabel,\n cohortRows: view.cohortRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n cohortId: row.cohortId,\n displayName: row.displayName,\n homeCityName: row.homeCityName,\n pressureScore: row.pressureScore,\n statusLabel: row.statusLabel,\n publicSummary: row.publicSummary\n })),\n semiNamedRows: view.semiNamedRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n actorId: row.actorId,\n displayName: row.displayName,\n roleLabel: row.roleLabel,\n publicSummary: row.publicSummary,\n upgradeEligible: row.upgradeEligible\n })),\n marketReactionRows: view.marketReactionRows.slice(0, NPC_COHORT_CONFIG.maxPromptRows).map((row) => ({\n reactionId: row.reactionId,\n reasonLabel: row.reasonLabel,\n pressureDelta: row.pressureDelta,\n publicSummary: row.publicSummary\n })),\n safeguards: view.safeguards,\n toolPermissions: view.toolPermissions\n };\n}\n\nfunction marketPressureAdjustmentForDefinition(worldState = {}, definition = {}) {\n const ledger = isPlainObject(worldState.npcCohortLedger) &&\n worldState.npcCohortLedger.schemaVersion === NPC_COHORT_SCHEMA_VERSION\n ? worldState.npcCohortLedger\n : null;\n if (!ledger) return 0;\n const goodsId = cleanId(definition.goodsId || definition.priceId, \"\");\n const category = cleanText(definition.marketCategory || definition.category, \"\", 40);\n let total = 0;\n for (const reaction of Array.isArray(ledger.marketReactions) ? ledger.marketReactions.slice(-NPC_COHORT_CONFIG.maxMarketReactions) : []) {\n const safe = normalizeMarketReaction(reaction, worldState);\n if (!safe) continue;\n const goodsMatch = goodsId && safe.goodsIds.includes(goodsId);\n const categoryMatch = category && safe.marketCategories.includes(category);\n if (!goodsMatch && !categoryMatch) continue;\n total += safe.pressureDelta;\n }\n for (const shadow of Array.isArray(ledger.shadowActors) ? ledger.shadowActors : []) {\n const safe = normalizeShadowActor(shadow, worldState);\n if (!safe || !category || !safe.marketCategories.includes(category)) continue;\n total += safe.pressureDelta;\n }\n return clampNumber(total, -NPC_COHORT_CONFIG.maxPressureAdjustment, NPC_COHORT_CONFIG.maxPressureAdjustment, 0);\n}\n\nmodule.exports = {\n buildNpcCohortLedgerSnapshot,\n buildNpcCohortSearchRows,\n buildNpcCohortView,\n createInitialNpcCohortLedger,\n ensureNpcCohortLedgerState,\n marketPressureAdjustmentForDefinition,\n runNpcCohortTickStep,\n summarizeNpcCohortsForPrompt\n};\n"}}, "test/publicAppSource.test.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1785745558.827026, "n": 12274, "h": "c208a28b6d9f6dc8", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S71.11 AI control audit panel reads route audit view only\", () => {\n const source = publicAppSource();\n const auditPanelSource = sourceBetween(\n source,\n \"function getAiControlAuditView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiControlAuditView = null;/);\n assert.match(source, /function renderAiControlAuditPanel\\(aiControlAuditView = currentAiControlAuditView\\)/);\n assert.match(source, /payload\\.aiControlAuditView/);\n assert.match(source, /currentAiControlAuditView = getAiControlAuditView\\(aiControlAuditView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlAuditPanel\\(\\)\\);/);\n assert.match(auditPanelSource, /publicPanel/);\n assert.match(auditPanelSource, /routeCostSummary/);\n assert.match(auditPanelSource, /toolCallSummary/);\n assert.match(auditPanelSource, /recentInvocations/);\n\n assert.doesNotMatch(auditPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(auditPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(auditPanelSource, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(auditPanelSource, /event_log|ai_change_proposals|world_state_json/);\n assert.doesNotMatch(auditPanelSource, /rawPrompt|rawProvider|providerPayload|statePatch/);\n assert.doesNotMatch(auditPanelSource, /prompt_retrieval_index|world_sessions/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.11 time skip feedback reads turn payload summary only\", () => {\n const source = publicAppSource();\n const timeSkipSource = sourceBetween(\n source,\n \"function appendTimeSkipFeedback(\",\n \"function getExamSceneTime(\"\n );\n\n assert.match(source, /function appendTimeSkipFeedback\\(timeSkip\\)/);\n assert.match(source, /appendTimeSkipFeedback\\(payload\\.timeSkip\\);/);\n assert.match(timeSkipSource, /timeSkip\\.summary/);\n assert.match(timeSkipSource, /timeSkip\\.interrupted/);\n assert.match(timeSkipSource, /timeSkip\\.nextTodo/);\n assert.doesNotMatch(timeSkipSource, /worldState\\?\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /worldState\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.12 browser does not read raw actor memory or session summary ledgers\", () => {\n const source = publicAppSource();\n\n assert.doesNotMatch(source, /worldState\\?\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\?\\.sessionSummary/);\n assert.doesNotMatch(source, /worldState\\.sessionSummary/);\n});\n\ntest(\"S71.4 browser loads saved state and information pages through player-state route\", () => {\n const source = publicAppSource();\n const loadSaveSource = sourceBetween(\n source,\n \"async function loadSaveSession(sessionId, options = {})\",\n \"function createPanelValue(\"\n );\n const informationSource = sourceBetween(\n source,\n \"async function fetchInformationPanelPage(tabId, patch = {})\",\n \"function renderInformationControls(\"\n );\n\n assert.match(loadSaveSource, /\\/api\\/game\\/player-state\\/\\$\\{sessionId\\}/);\n assert.doesNotMatch(loadSaveSource, /\\/api\\/game\\/state\\/\\$\\{sessionId\\}/);\n assert.match(informationSource, /\\/api\\/game\\/player-state\\/\\$\\{currentSessionId\\}\\?\\$\\{params\\.toString\\(\\)\\}/);\n assert.doesNotMatch(informationSource, /\\/api\\/game\\/state\\/\\$\\{currentSessionId\\}/);\n});\n"}, "cf1abbfd564b83f8267b4e6b9f560495909db612": {"t": 1785753936.3350756, "n": 12274, "h": "c208a28b6d9f6dc8", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S71.11 AI control audit panel reads route audit view only\", () => {\n const source = publicAppSource();\n const auditPanelSource = sourceBetween(\n source,\n \"function getAiControlAuditView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiControlAuditView = null;/);\n assert.match(source, /function renderAiControlAuditPanel\\(aiControlAuditView = currentAiControlAuditView\\)/);\n assert.match(source, /payload\\.aiControlAuditView/);\n assert.match(source, /currentAiControlAuditView = getAiControlAuditView\\(aiControlAuditView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlAuditPanel\\(\\)\\);/);\n assert.match(auditPanelSource, /publicPanel/);\n assert.match(auditPanelSource, /routeCostSummary/);\n assert.match(auditPanelSource, /toolCallSummary/);\n assert.match(auditPanelSource, /recentInvocations/);\n\n assert.doesNotMatch(auditPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(auditPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(auditPanelSource, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(auditPanelSource, /event_log|ai_change_proposals|world_state_json/);\n assert.doesNotMatch(auditPanelSource, /rawPrompt|rawProvider|providerPayload|statePatch/);\n assert.doesNotMatch(auditPanelSource, /prompt_retrieval_index|world_sessions/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.11 time skip feedback reads turn payload summary only\", () => {\n const source = publicAppSource();\n const timeSkipSource = sourceBetween(\n source,\n \"function appendTimeSkipFeedback(\",\n \"function getExamSceneTime(\"\n );\n\n assert.match(source, /function appendTimeSkipFeedback\\(timeSkip\\)/);\n assert.match(source, /appendTimeSkipFeedback\\(payload\\.timeSkip\\);/);\n assert.match(timeSkipSource, /timeSkip\\.summary/);\n assert.match(timeSkipSource, /timeSkip\\.interrupted/);\n assert.match(timeSkipSource, /timeSkip\\.nextTodo/);\n assert.doesNotMatch(timeSkipSource, /worldState\\?\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /worldState\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.12 browser does not read raw actor memory or session summary ledgers\", () => {\n const source = publicAppSource();\n\n assert.doesNotMatch(source, /worldState\\?\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\?\\.sessionSummary/);\n assert.doesNotMatch(source, /worldState\\.sessionSummary/);\n});\n\ntest(\"S71.4 browser loads saved state and information pages through player-state route\", () => {\n const source = publicAppSource();\n const loadSaveSource = sourceBetween(\n source,\n \"async function loadSaveSession(sessionId, options = {})\",\n \"function createPanelValue(\"\n );\n const informationSource = sourceBetween(\n source,\n \"async function fetchInformationPanelPage(tabId, patch = {})\",\n \"function renderInformationControls(\"\n );\n\n assert.match(loadSaveSource, /\\/api\\/game\\/player-state\\/\\$\\{sessionId\\}/);\n assert.doesNotMatch(loadSaveSource, /\\/api\\/game\\/state\\/\\$\\{sessionId\\}/);\n assert.match(informationSource, /\\/api\\/game\\/player-state\\/\\$\\{currentSessionId\\}\\?\\$\\{params\\.toString\\(\\)\\}/);\n assert.doesNotMatch(informationSource, /\\/api\\/game\\/state\\/\\$\\{currentSessionId\\}/);\n});\n"}, "746370ab187c90e0a92672352742470d97a43ab9": {"t": 1785753940.5950046, "n": 10923, "h": "1d2148b46dcc7b48", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.11 time skip feedback reads turn payload summary only\", () => {\n const source = publicAppSource();\n const timeSkipSource = sourceBetween(\n source,\n \"function appendTimeSkipFeedback(\",\n \"function getExamSceneTime(\"\n );\n\n assert.match(source, /function appendTimeSkipFeedback\\(timeSkip\\)/);\n assert.match(source, /appendTimeSkipFeedback\\(payload\\.timeSkip\\);/);\n assert.match(timeSkipSource, /timeSkip\\.summary/);\n assert.match(timeSkipSource, /timeSkip\\.interrupted/);\n assert.match(timeSkipSource, /timeSkip\\.nextTodo/);\n assert.doesNotMatch(timeSkipSource, /worldState\\?\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /worldState\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.12 browser does not read raw actor memory or session summary ledgers\", () => {\n const source = publicAppSource();\n\n assert.doesNotMatch(source, /worldState\\?\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\?\\.sessionSummary/);\n assert.doesNotMatch(source, /worldState\\.sessionSummary/);\n});\n\ntest(\"S71.4 browser loads saved state and information pages through player-state route\", () => {\n const source = publicAppSource();\n const loadSaveSource = sourceBetween(\n source,\n \"async function loadSaveSession(sessionId, options = {})\",\n \"function createPanelValue(\"\n );\n const informationSource = sourceBetween(\n source,\n \"async function fetchInformationPanelPage(tabId, patch = {})\",\n \"function renderInformationControls(\"\n );\n\n assert.match(loadSaveSource, /\\/api\\/game\\/player-state\\/\\$\\{sessionId\\}/);\n assert.doesNotMatch(loadSaveSource, /\\/api\\/game\\/state\\/\\$\\{sessionId\\}/);\n assert.match(informationSource, /\\/api\\/game\\/player-state\\/\\$\\{currentSessionId\\}\\?\\$\\{params\\.toString\\(\\)\\}/);\n assert.doesNotMatch(informationSource, /\\/api\\/game\\/state\\/\\$\\{currentSessionId\\}/);\n});\n"}, "29ea72dc82a0d69cff6de9bd44c2679273bdaf36": {"t": 1785753941.978561, "n": 10102, "h": "578486edd8596a45", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.11 time skip feedback reads turn payload summary only\", () => {\n const source = publicAppSource();\n const timeSkipSource = sourceBetween(\n source,\n \"function appendTimeSkipFeedback(\",\n \"function getExamSceneTime(\"\n );\n\n assert.match(source, /function appendTimeSkipFeedback\\(timeSkip\\)/);\n assert.match(source, /appendTimeSkipFeedback\\(payload\\.timeSkip\\);/);\n assert.match(timeSkipSource, /timeSkip\\.summary/);\n assert.match(timeSkipSource, /timeSkip\\.interrupted/);\n assert.match(timeSkipSource, /timeSkip\\.nextTodo/);\n assert.doesNotMatch(timeSkipSource, /worldState\\?\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /worldState\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.12 browser does not read raw actor memory or session summary ledgers\", () => {\n const source = publicAppSource();\n\n assert.doesNotMatch(source, /worldState\\?\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\.actorMemoryLedger/);\n assert.doesNotMatch(source, /worldState\\?\\.sessionSummary/);\n assert.doesNotMatch(source, /worldState\\.sessionSummary/);\n});\n"}, "e18720e8b26169c3a53ef4674111468118248f4b": {"t": 1785753943.3677468, "n": 9717, "h": "bcb4f3454be6bef1", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n\ntest(\"S70.11 time skip feedback reads turn payload summary only\", () => {\n const source = publicAppSource();\n const timeSkipSource = sourceBetween(\n source,\n \"function appendTimeSkipFeedback(\",\n \"function getExamSceneTime(\"\n );\n\n assert.match(source, /function appendTimeSkipFeedback\\(timeSkip\\)/);\n assert.match(source, /appendTimeSkipFeedback\\(payload\\.timeSkip\\);/);\n assert.match(timeSkipSource, /timeSkip\\.summary/);\n assert.match(timeSkipSource, /timeSkip\\.interrupted/);\n assert.match(timeSkipSource, /timeSkip\\.nextTodo/);\n assert.doesNotMatch(timeSkipSource, /worldState\\?\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /worldState\\.timeSkip/);\n assert.doesNotMatch(timeSkipSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n"}, "16d5f08b5c672c8306aa89e2ec51c04ab8cfce46": {"t": 1785753944.7081842, "n": 8952, "h": "bc52bb9ba4e9bf0e", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n\ntest(\"S70.10 player monthly briefing panel reads route view and turn feedback\", () => {\n const source = publicAppSource();\n const monthlyPanelSource = sourceBetween(\n source,\n \"function getPlayerMonthlyBriefingView(\",\n \"function getAiSettingsView(\"\n );\n\n assert.match(source, /let currentPlayerMonthlyBriefingView = null;/);\n assert.match(source, /function renderPlayerMonthlyBriefingPanel\\(playerMonthlyBriefingView = currentPlayerMonthlyBriefingView\\)/);\n assert.match(source, /function appendPlayerMonthlyBriefingFeedback\\(playerMonthlyBriefing\\)/);\n assert.match(source, /payload\\.playerMonthlyBriefingView/);\n assert.match(source, /appendPlayerMonthlyBriefingFeedback\\(payload\\.playerMonthlyBriefing\\);/);\n assert.match(source, /appendOptionalPanel\\(renderPlayerMonthlyBriefingPanel\\(\\)\\);/);\n\n assert.match(monthlyPanelSource, /getPlayerMonthlyBriefingView\\(playerMonthlyBriefingView\\)/);\n assert.match(monthlyPanelSource, /view\\?\\.latest/);\n assert.match(monthlyPanelSource, /recentReports/);\n assert.match(monthlyPanelSource, /publicSummary/);\n assert.match(monthlyPanelSource, /actionItems/);\n assert.match(monthlyPanelSource, /riskItems/);\n assert.match(monthlyPanelSource, /sourceRefs/);\n\n assert.doesNotMatch(monthlyPanelSource, /worldState\\?\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /worldState\\.playerMonthlyBriefing/);\n assert.doesNotMatch(monthlyPanelSource, /raw|proposal|audit|prompt|table|path|key/i);\n});\n"}, "cbb8b1e894820845885a6c501d7a2500291b5a26": {"t": 1785753946.1526377, "n": 7472, "h": "917b8fd1495f496a", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n\ntest(\"S70.9 AI control panel reads route AI views instead of raw world state settings\", () => {\n const source = publicAppSource();\n const aiPanelSource = sourceBetween(\n source,\n \"function getAiSettingsView(\",\n \"function appendOptionalPanel(\"\n );\n\n assert.match(source, /let currentAiSettingsView = null;/);\n assert.match(source, /let currentAiInvocationSummaryView = null;/);\n assert.match(source, /function renderAiControlPanel\\(aiSettingsView = currentAiSettingsView/);\n assert.match(source, /payload\\.aiSettingsView/);\n assert.match(source, /payload\\.aiInvocationSummaryView/);\n assert.match(source, /\\/api\\/ai\\/settings\\/\\$\\{currentSessionId\\}/);\n assert.match(source, /appendOptionalPanel\\(renderAiControlPanel\\(\\)\\);/);\n\n assert.doesNotMatch(aiPanelSource, /worldState\\?\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /worldState\\.aiSettings/);\n assert.doesNotMatch(aiPanelSource, /raw proposal|raw audit|raw provider/i);\n assert.doesNotMatch(aiPanelSource, /retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions/);\n assert.doesNotMatch(aiPanelSource, /OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/);\n});\n"}, "a4f4e8d00fa8d77c5925b06a42c1692a4a8046d3": {"t": 1785753947.5173578, "n": 6292, "h": "20984866a9472f11", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\nfunction sourceBetween(source, startToken, endToken) {\n const start = source.indexOf(startToken);\n assert.notEqual(start, -1, `${startToken} not found`);\n const end = source.indexOf(endToken, start + startToken.length);\n assert.notEqual(end, -1, `${endToken} not found after ${startToken}`);\n return source.slice(start, end);\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n\ntest(\"imperial exam archive panel reads route views and safe exam snapshots\", () => {\n const source = publicAppSource();\n const archiveSource = sourceBetween(\n source,\n \"function getLatestExamArchiveEntry()\",\n \"function createInformationSelect(\"\n );\n const appointmentBlockSource = sourceBetween(\n source,\n \"function getAppointmentTrackDisplay(\",\n \"function renderStudyProfilePanel(\"\n );\n\n assert.match(source, /let currentAppointmentTrackView = null;/);\n assert.match(source, /const PUBLIC_APPOINTMENT_AUTHORITY =/);\n assert.match(source, /function getAppointmentTrackView\\(appointmentTrackView\\)/);\n assert.match(source, /function pickAppointmentTrackView\\(primaryView, fallbackView\\)/);\n assert.match(source, /function createAppointmentTrackBlock\\(appointmentTrackView/);\n assert.match(source, /function renderImperialExamArchivePanel\\(\\)/);\n assert.match(source, /payload\\.appointmentTrackView/);\n assert.match(source, /currentAppointmentTrackView = getAppointmentTrackView\\(appointmentTrackView\\);/);\n assert.match(source, /appendOptionalPanel\\(renderImperialExamArchivePanel\\(\\)\\);/);\n assert.match(appointmentBlockSource, /PUBLIC_APPOINTMENT_AUTHORITY/);\n assert.doesNotMatch(appointmentBlockSource, /appendIfText\\(block, \"p\", display\\.authorityBoundary/);\n\n assert.match(archiveSource, /currentStudyProfileView/);\n assert.match(archiveSource, /currentExamProcedureView/);\n assert.match(archiveSource, /currentExaminerPanelView/);\n assert.match(archiveSource, /currentExamHonorView/);\n assert.match(archiveSource, /currentRelationshipView/);\n assert.match(archiveSource, /currentWorldPeopleView/);\n assert.match(archiveSource, /currentAppointmentTrackView/);\n assert.match(archiveSource, /latestExam\\?\\.examNetwork/);\n assert.match(archiveSource, /latestExam\\?\\.appointmentTrack/);\n assert.match(archiveSource, /pickAppointmentTrackView\\(currentAppointmentTrackView, latestExam\\?\\.appointmentTrack\\)/);\n assert.match(archiveSource, /appointmentTrackView/);\n\n assert.doesNotMatch(archiveSource, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(archiveSource, /worldState\\.activeExam\\.procedure/);\n assert.doesNotMatch(archiveSource, /worldState\\.examHonorLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.appointmentTrack/);\n assert.doesNotMatch(archiveSource, /worldState\\.relationshipLedger/);\n assert.doesNotMatch(archiveSource, /worldState\\.eventHistory/);\n assert.doesNotMatch(archiveSource, /retrievalContext/);\n assert.doesNotMatch(archiveSource, /providerProposal|provider proposal|raw proposal|raw audit/i);\n assert.doesNotMatch(archiveSource, /prompt_retrieval_index|event_archive_index|world_state_json/);\n});\n"}, "65344622fa805bc8c63147d811d34cf38a1eb7f1": {"t": 1785753950.803264, "n": 3264, "h": "aa595f57d1cf0231", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /let currentExamHonorView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /function createExamHonorBlock\\(examHonorView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /payload\\.examHonorView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /currentExamHonorView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /createExamHonorBlock\\(payload\\.examHonorView \\|\\| payload\\.examHonor/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.match(source, /appendOptionalPanel\\(createExamHonorBlock\\(currentExamHonorView/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\?\\.examHonorLedger/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n"}, "9002bc276dd2d8667cdba8031ac24f364a537273": {"t": 1785753951.9476786, "n": 2783, "h": "6a0972ac968811ce", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /let currentExaminerPanelView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /function createExaminerPanelBlock\\(examinerPanelView/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /payload\\.examinerPanelView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /examProcedureView\\.examinerPanelView/);\n assert.match(source, /currentExaminerPanelView/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /createExaminerPanelBlock\\(payload\\.examinerPanelView/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n"}, "9a53791cacb187c36c12a09d8b5918190ef29209": {"t": 1785753953.3104458, "n": 2389, "h": "538375f573cde3a4", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n\ntest(\"exam procedure panel reads route examProcedureView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentExamProcedureView = null;/);\n assert.match(source, /function renderExamProcedurePanel\\(examProcedureView = currentExamProcedureView\\)/);\n assert.match(source, /payload\\.examProcedureView/);\n assert.match(source, /examProcedureView\\.rollLifecycle/);\n assert.match(source, /createExamProcedureBlock\\(payload\\.examProcedureView \\|\\| payload\\.examProcedure\\)/);\n assert.match(source, /appendOptionalPanel\\(renderExamProcedurePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.examProcedure/);\n assert.doesNotMatch(source, /worldState\\.activeExam\\.procedure\\./);\n});\n"}, "aeb35a2bdf5ee7c59724ce5a2870b6b598042f00": {"t": 1785753955.7010138, "n": 1671, "h": "2b673c1d622bf465", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /studyProfileView\\.teacherFeedback/);\n assert.match(source, /studyProfileView\\.academyNetwork/);\n assert.match(source, /studyProfileView\\.smallExercises/);\n assert.match(source, /currentStudyProfileView\\?\\.academyNetwork\\?\\.teacher\\?\\.name/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherFeedback/);\n assert.doesNotMatch(source, /createPanelValue\\(\"师承\", player\\.teacher/);\n});\n"}, "4d113e11e8e3654cfcd68062aa6b0e86c691387c": {"t": 1785753957.002991, "n": 1252, "h": "870c07e3c3628cd7", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n\ntest(\"scholar study profile panel reads route studyProfileView\", () => {\n const source = publicAppSource();\n\n assert.match(source, /let currentStudyProfileView = null;/);\n assert.match(source, /function renderStudyProfilePanel\\(studyProfileView = currentStudyProfileView\\)/);\n assert.match(source, /payload\\.studyProfileView/);\n assert.match(source, /appendOptionalPanel\\(renderStudyProfilePanel\\(\\)\\);/);\n assert.doesNotMatch(source, /worldState\\?\\.studyProfile/);\n assert.doesNotMatch(source, /worldState\\.studyProfile\\.teacherAdvice\\.forEach/);\n});\n"}, "acfe9c1f4121f03544ea0705146f3a1e70f74e62": {"t": 1785753958.3156586, "n": 692, "h": "7cacfb73ca036276", "c": "const assert = require(\"node:assert/strict\");\nconst { readFileSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst test = require(\"node:test\");\n\nfunction publicAppSource() {\n return readFileSync(path.join(__dirname, \"..\", \"public\", \"app.js\"), \"utf8\");\n}\n\ntest(\"save-load narrative replay uses safe event archive projection\", () => {\n const source = publicAppSource();\n\n assert.match(source, /const history = archiveNarrativeEntriesFromPayload\\(payload\\);/);\n assert.match(source, /function archiveNarrativeEntriesFromPayload\\(payload\\)/);\n assert.match(source, /eventArchiveView/);\n assert.doesNotMatch(source, /const history = payload\\.worldState\\.eventHistory/);\n});\n"}}, "test/cityPolicyHiddenRedaction.test.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1786068266.31902, "n": 4263, "h": "d347a901b0231f60", "c": "const test = require(\"node:test\");\nconst assert = require(\"node:assert/strict\");\n\nconst { buildPlayerAiActorProfile } = require(\"../src/game/aiActorProfiles\");\nconst {\n applyCityPolicyOutcome,\n normalizeCityPolicyProposal,\n resolveCityPolicy\n} = require(\"../src/game/cityPolicyResolver\");\nconst { createInitialState } = require(\"../src/game/initialState\");\nconst {\n assertRedactedStateSafe,\n buildPlayerStateEnvelope\n} = require(\"../src/game/redactedState\");\nconst {\n buildResolverInputContext,\n filterResolverInputForActor\n} = require(\"../src/game/resolverInputContext\");\n\nfunction scopedEconomyEvidence(worldState, actorProfile) {\n const context = filterResolverInputForActor(\n buildResolverInputContext(worldState, { actorProfile }),\n actorProfile\n );\n const evidence = (context.economy || []).find((item) =>\n (item.scopeRefs || []).some((scopeRef) => actorProfile.jurisdictionRefs.includes(scopeRef))\n );\n assert.ok(evidence, \"缺少辖区经济证据\");\n return evidence;\n}\n\ntest(\"S71.5 city policy normalizer strips hidden/raw text and private result refs\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"清查知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const normalized = normalizeCityPolicyProposal({\n policyType: \"relief\",\n publicSummary: \"SEALED_CITY_POLICY hiddenNotes rawSql /mnt/e/LSMNQ/data/sessions/secret.json sk-test-secret\",\n evidenceRefs: [\"evidence:economy:test\"],\n privateResultRefs: [\"raw_table:city\"],\n statePatch: { treasury: 999999 }\n }, { actorProfile });\n const payload = JSON.stringify(normalized);\n\n assert.equal(normalized.publicSummary, \"开仓赈济待服务器裁决。\");\n assert.deepEqual(normalized.privateResultRefs, []);\n assert.equal(normalized.safetyFlags.length > 0, true);\n assert.doesNotMatch(payload, /SEALED_CITY_POLICY|hiddenNotes|rawSql|statePatch|sk-test-secret|\\/mnt\\/e\\/LSMNQ/);\n});\n\ntest(\"S71.5 rejected polluted city policy outcome keeps audit hidden-safe\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"风纪知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const evidence = scopedEconomyEvidence(worldState, actorProfile);\n const outcome = resolveCityPolicy(worldState, {\n policyType: \"relief\",\n evidenceRefs: [evidence.refId],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"SEALED_CITY_POLICY provider proposal prompt_retrieval_index data/sessions/secret.json\",\n worldState: { treasury: 999999 },\n rawSql: \"update world_sessions set world_state_json='SEALED'\"\n }, { actorProfile });\n const payload = JSON.stringify(outcome);\n\n assert.equal(outcome.status, \"rejected\");\n assert.match(outcome.rejectionReasons.join(\" \"), /禁止来源|直写字段/);\n assert.equal(outcome.auditRecord.safety.proposalPayloadIncluded, false);\n assert.equal(outcome.auditRecord.safety.stateSnapshotIncluded, false);\n assert.equal(outcome.auditRecord.safety.sqlIncluded, false);\n assert.doesNotMatch(\n payload,\n /SEALED_CITY_POLICY|prompt_retrieval_index|world_sessions|world_state_json|data\\/sessions|rawSql|statePatch|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY/\n );\n});\n\ntest(\"S71.5 accepted city policy ledger stays out of redacted player state\", () => {\n const worldState = createInitialState({ role: \"magistrate\", playerName: \"赈济知县\" });\n const actorProfile = buildPlayerAiActorProfile(worldState);\n const evidence = scopedEconomyEvidence(worldState, actorProfile);\n const outcome = resolveCityPolicy(worldState, {\n policyType: \"relief\",\n evidenceRefs: [evidence.refId],\n jurisdictionRef: actorProfile.jurisdictionRefs[0],\n publicSummary: \"按辖区钱粮证据开仓赈济。\",\n intensity: 1\n }, { actorProfile });\n\n assert.equal(outcome.status, \"accepted\");\n applyCityPolicyOutcome(worldState, outcome);\n assert.equal(worldState.cityPolicyLedger.records.length, 1);\n\n const envelope = buildPlayerStateEnvelope(worldState);\n const payload = JSON.stringify(envelope);\n\n assert.equal(envelope.worldState.cityPolicyLedger, undefined);\n assert.equal(envelope.worldState.eventHistory.length > 0, true);\n assert.doesNotMatch(payload, /cityPolicyLedger|auditRecord|rawSql|statePatch|SEALED_CITY_POLICY/);\n assertRedactedStateSafe(envelope);\n});\n"}}, "src/game/aiSettings.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1786068267.6322205, "n": 28538, "h": "75159c10bbeeb980", "c": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst {\n MODEL_TASK_TYPES,\n REVIEW_ONLY_TASK_TYPES,\n SUPPORTED_PROVIDERS,\n TOOL_DISABLED_TASK_TYPES,\n buildDefaultModelRoutePolicy,\n normalizeProviderName,\n resolveModelForTask,\n validateModelRoutePolicy\n} = require(\"../ai/modelRoutePolicy\");\nconst {\n AI_INVOCATION_LOG_LIMIT,\n AI_SETTING_PRESETS,\n AI_SETTINGS_SCHEMA_VERSION,\n AI_SAFETY_STRICTNESS,\n AI_TASK_LABELS\n} = require(\"./aiSettingsConfig\");\n\nconst GLOBAL_AI_SETTINGS_SCHEMA_VERSION = \"s80-ai-global-settings.v1\";\nconst DEFAULT_GLOBAL_AI_SETTINGS_PATH = path.join(__dirname, \"..\", \"..\", \"data\", \"settings\", \"ai-global-settings.json\");\n\nconst SENSITIVE_AI_SETTINGS_PATTERN =\n /(hiddenNotes|hiddenIntent|raw[_ -]?(?:provider|audit|table|ledger|prompt)|\\b(?:statePatch|worldState|rawSql|server\\.)\\b|retrievalContext|prompt_retrieval_index|event_archive_index|world_sessions|ai_change_proposals|event_log|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|data[\\\\/](?:sessions|audit)|[A-Za-z]:[\\\\/][^\\s\"'<>]+|\\\\\\\\[^\\\\/\\s\"'<>]+[\\\\/][^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nfunction normalizeSettingKeyForSafety(key) {\n return String(key).toLowerCase().replace(/[\\s_.-]+/g, \"\");\n}\n\nconst FORBIDDEN_SETTING_KEYS = new Set([\n \"hidden\",\n \"hiddenNotes\",\n \"hiddenIntent\",\n \"private\",\n \"raw\",\n \"rawAudit\",\n \"rawLedger\",\n \"rawProviderPayload\",\n \"rawProvider\",\n \"rawPrompt\",\n \"rawSql\",\n \"hiddenRaw\",\n \"statePatch\",\n \"worldState\",\n \"directWrite\",\n \"directStateWrite\",\n \"mayWriteState\",\n \"mayCallServerResolvers\",\n \"server\",\n \"serverResolver\",\n \"serverResolvers\",\n \"resolver\",\n \"readHidden\",\n \"writeDatabase\",\n \"writeSql\",\n \"path\",\n \"localPath\",\n \"filePath\",\n \"basePath\",\n \"baseURL\",\n \"baseUrl\",\n \"apiKey\",\n \"accessToken\",\n \"authToken\",\n \"bearerToken\",\n \"key\",\n \"secret\",\n \"token\"\n].map((key) => normalizeSettingKeyForSafety(key)));\n\nconst ROUTE_PATCH_KEYS = new Set([\n \"provider\",\n \"model\",\n \"maxOutputTokens\",\n \"toolBudget\",\n \"temperature\"\n]);\n\nfunction clampNumber(value, fallback, min, max) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, parsed));\n}\n\nfunction clampInt(value, fallback, min, max) {\n return Math.trunc(clampNumber(value, fallback, min, max));\n}\n\nfunction cleanVisibleText(value, fallback = \"\", maxLength = 120) {\n if (typeof value !== \"string\") return fallback;\n const text = value.trim().replace(/\\s+/g, \" \");\n if (!text || SENSITIVE_AI_SETTINGS_PATTERN.test(text)) return fallback;\n return text.slice(0, maxLength);\n}\n\nfunction shouldUseDefaultGlobalSettingsPath(env = process.env) {\n if (env !== process.env) return false;\n return !process.argv.includes(\"--test\");\n}\n\nfunction resolveGlobalAiSettingsPath(env = process.env, options = {}) {\n const explicitPath = env && Object.prototype.hasOwnProperty.call(env, \"AI_GLOBAL_SETTINGS_PATH\")\n ? env.AI_GLOBAL_SETTINGS_PATH\n : undefined;\n if (explicitPath) return path.resolve(String(explicitPath));\n if (shouldUseDefaultGlobalSettingsPath(env)) return DEFAULT_GLOBAL_AI_SETTINGS_PATH;\n return null;\n}\n\nfunction createGlobalSettingsError(message) {\n const error = new Error(message);\n error.statusCode = 500;\n return error;\n}\n\nfunction assertSettingsSafe(value, path = \"aiSettings\") {\n if (typeof value === \"string\") {\n if (SENSITIVE_AI_SETTINGS_PATTERN.test(value)) {\n throw new Error(`${path} 含有 hidden/raw/server/path/key 等禁止设置内容。`);\n }\n return;\n }\n if (!value || typeof value !== \"object\") return;\n\n for (const [key, child] of Object.entries(value)) {\n const normalizedKey = normalizeSettingKeyForSafety(key);\n if (FORBIDDEN_SETTING_KEYS.has(normalizedKey) || SENSITIVE_AI_SETTINGS_PATTERN.test(key)) {\n const safeKey = cleanVisibleText(key, \"[blocked]\", 40);\n throw new Error(`${path}.${safeKey} 是 AI 设置禁止字段。`);\n }\n assertSettingsSafe(child, `${path}.${key}`);\n }\n}\n\nfunction normalizePreset(value) {\n const preset = String(value || \"balanced\").trim();\n return AI_SETTING_PRESETS[preset] ? preset : \"balanced\";\n}\n\nfunction normalizeSafetyStrictness(value, fallback = \"standard\") {\n const normalized = String(value || fallback).trim();\n return AI_SAFETY_STRICTNESS.includes(normalized) ? normalized : fallback;\n}\n\nfunction normalizePatchShape(patch = {}) {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) return {};\n const controls = patch.controls && typeof patch.controls === \"object\" && !Array.isArray(patch.controls)\n ? patch.controls\n : {};\n return {\n ...patch,\n outputScale: patch.outputScale ?? controls.outputScale,\n toolBudgetScale: patch.toolBudgetScale ?? controls.toolBudgetScale,\n maxConcurrency: patch.maxConcurrency ?? controls.maxConcurrency,\n safetyStrictness: patch.safetyStrictness ?? controls.safetyStrictness,\n criticEnabled: patch.criticEnabled ?? controls.criticEnabled,\n safetyGateEnabled: patch.safetyGateEnabled ?? controls.safetyGateEnabled\n };\n}\n\nfunction normalizeInvocationLog(entries = []) {\n if (!Array.isArray(entries)) return [];\n return entries.slice(-AI_INVOCATION_LOG_LIMIT).map((entry = {}) => ({\n id: cleanVisibleText(entry.id, `ai-invocation:${Date.now()}`, 80),\n taskType: cleanVisibleText(entry.taskType, \"narrator\", 40),\n provider: normalizeProviderName(entry.provider || \"mock\"),\n model: redactModelName(entry.model || \"mock\"),\n status: cleanVisibleText(entry.status, \"completed\", 32),\n durationMs: clampInt(entry.durationMs, 0, 0, 300000),\n maxOutputTokens: clampInt(entry.maxOutputTokens, 0, 0, 16000),\n toolCallCount: clampInt(entry.toolCallCount, 0, 0, 100),\n rejectedToolCallCount: clampInt(entry.rejectedToolCallCount, 0, 0, 100),\n rejectionReasons: Array.isArray(entry.rejectionReasons)\n ? entry.rejectionReasons.map((reason) => cleanVisibleText(reason, \"\", 80)).filter(Boolean).slice(0, 4)\n : [],\n recordedTurn: clampInt(entry.recordedTurn, 0, 0, Number.MAX_SAFE_INTEGER)\n }));\n}\n\nfunction normalizeTraceFeedbackLog(entries = []) {\n if (!Array.isArray(entries)) return [];\n return entries.slice(-24).map((entry = {}) => ({\n schemaVersion: cleanVisibleText(entry.schemaVersion, \"s92.9-ai-trace-feedback.v1\", 64),\n feedbackRecordId: cleanVisibleText(entry.feedbackRecordId || entry.id, `ai-feedback:${Date.now()}`, 120),\n traceId: cleanVisibleText(entry.traceId, \"ai-trace\", 120),\n taskType: cleanVisibleText(entry.taskType, \"narrator\", 40),\n taskLabel: cleanVisibleText(entry.taskLabel, AI_TASK_LABELS[entry.taskType] || \"AI 调动\", 40),\n feedbackId: cleanVisibleText(entry.feedbackId || entry.feedback, \"useful\", 64),\n label: cleanVisibleText(entry.label, \"有用\", 40),\n recordedTurn: clampInt(entry.recordedTurn, 0, 0, Number.MAX_SAFE_INTEGER),\n createdAt: cleanVisibleText(entry.createdAt, new Date().toISOString(), 64),\n changesGameState: false\n }));\n}\n\nfunction normalizeRoutePatch(taskType, patch = {}) {\n if (!MODEL_TASK_TYPES.includes(taskType)) {\n throw new Error(`未知 AI 任务类型:${taskType}`);\n }\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new Error(`${taskType} AI 路由设置必须是对象。`);\n }\n\n for (const key of Object.keys(patch)) {\n if (!ROUTE_PATCH_KEYS.has(key)) {\n throw new Error(`${taskType} AI 路由设置包含不支持字段:${key}`);\n }\n }\n\n const normalized = {};\n if (patch.provider !== undefined) {\n const provider = normalizeProviderName(patch.provider);\n if (!SUPPORTED_PROVIDERS.includes(provider)) {\n throw new Error(`${taskType} provider 不受支持。`);\n }\n normalized.provider = provider;\n }\n if (patch.model !== undefined) {\n const model = cleanVisibleText(patch.model, \"\", 96);\n if (!model) throw new Error(`${taskType} model 不可包含 hidden/raw/server/path/key 内容。`);\n normalized.model = model;\n }\n if (patch.maxOutputTokens !== undefined) {\n normalized.maxOutputTokens = clampInt(patch.maxOutputTokens, 1200, 128, 16000);\n }\n if (patch.toolBudget !== undefined) {\n normalized.toolBudget = clampInt(patch.toolBudget, 0, 0, 20);\n }\n if (patch.temperature !== undefined) {\n normalized.temperature = Number(clampNumber(patch.temperature, 0.35, 0, 1).toFixed(2));\n }\n if (TOOL_DISABLED_TASK_TYPES.includes(taskType) && normalized.toolBudget && normalized.toolBudget > 0) {\n normalized.toolBudget = 0;\n }\n\n return normalized;\n}\n\nfunction normalizeTaskRoutes(taskRoutes = {}) {\n if (!taskRoutes || typeof taskRoutes !== \"object\" || Array.isArray(taskRoutes)) return {};\n const normalized = {};\n for (const [taskType, routePatch] of Object.entries(taskRoutes)) {\n normalized[taskType] = normalizeRoutePatch(taskType, routePatch);\n }\n return normalized;\n}\n\nfunction buildDefaultAiSettings(patch = {}, options = {}) {\n assertSettingsSafe(patch);\n if (!options.allowObservabilityPatch && patch.observability !== undefined) {\n throw new Error(\"AI 设置观测记录由服务器维护。\");\n }\n const normalizedPatch = normalizePatchShape(patch);\n const presetId = normalizePreset(normalizedPatch.preset);\n const preset = AI_SETTING_PRESETS[presetId];\n const controls = {\n outputScale: Number(clampNumber(normalizedPatch.outputScale, preset.outputScale, 0.5, 1.75).toFixed(2)),\n toolBudgetScale: Number(clampNumber(normalizedPatch.toolBudgetScale, preset.toolBudgetScale, 0, 1.5).toFixed(2)),\n maxConcurrency: clampInt(normalizedPatch.maxConcurrency, preset.maxConcurrency, 1, 4),\n safetyStrictness: normalizeSafetyStrictness(normalizedPatch.safetyStrictness, preset.safetyStrictness),\n criticEnabled: normalizedPatch.criticEnabled === undefined\n ? Boolean(preset.criticEnabled)\n : Boolean(normalizedPatch.criticEnabled),\n safetyGateEnabled: normalizedPatch.safetyGateEnabled === undefined\n ? Boolean(preset.safetyGateEnabled)\n : Boolean(normalizedPatch.safetyGateEnabled)\n };\n\n return {\n schemaVersion: AI_SETTINGS_SCHEMA_VERSION,\n preset: presetId,\n controls,\n taskRoutes: normalizeTaskRoutes(normalizedPatch.taskRoutes),\n observability: {\n recentInvocations: options.allowObservabilityPatch\n ? normalizeInvocationLog(patch.observability?.recentInvocations)\n : [],\n traceFeedback: options.allowObservabilityPatch\n ? normalizeTraceFeedbackLog(patch.observability?.traceFeedback)\n : []\n }\n };\n}\n\nfunction mergeAiSettings(base, patch = {}, options = {}) {\n assertSettingsSafe(patch);\n if (!options.allowObservabilityPatch && patch.observability !== undefined) {\n throw new Error(\"AI 设置观测记录由服务器维护。\");\n }\n const normalizedPatch = normalizePatchShape(patch);\n const presetId = normalizedPatch.preset === undefined ? base.preset : normalizePreset(normalizedPatch.preset);\n const preset = AI_SETTING_PRESETS[presetId];\n const baseControls = base.controls || {};\n const controls = {\n outputScale: Number(clampNumber(\n normalizedPatch.outputScale,\n baseControls.outputScale ?? preset.outputScale,\n 0.5,\n 1.75\n ).toFixed(2)),\n toolBudgetScale: Number(clampNumber(\n normalizedPatch.toolBudgetScale,\n baseControls.toolBudgetScale ?? preset.toolBudgetScale,\n 0,\n 1.5\n ).toFixed(2)),\n maxConcurrency: clampInt(\n normalizedPatch.maxConcurrency,\n baseControls.maxConcurrency ?? preset.maxConcurrency,\n 1,\n 4\n ),\n safetyStrictness: normalizeSafetyStrictness(\n normalizedPatch.safetyStrictness,\n baseControls.safetyStrictness || preset.safetyStrictness\n ),\n criticEnabled: normalizedPatch.criticEnabled === undefined\n ? Boolean(baseControls.criticEnabled ?? preset.criticEnabled)\n : Boolean(normalizedPatch.criticEnabled),\n safetyGateEnabled: normalizedPatch.safetyGateEnabled === undefined\n ? Boolean(baseControls.safetyGateEnabled ?? preset.safetyGateEnabled)\n : Boolean(normalizedPatch.safetyGateEnabled)\n };\n\n return {\n schemaVersion: AI_SETTINGS_SCHEMA_VERSION,\n preset: presetId,\n controls,\n taskRoutes: {\n ...(base.taskRoutes || {}),\n ...normalizeTaskRoutes(normalizedPatch.taskRoutes)\n },\n observability: {\n recentInvocations: options.allowObservabilityPatch && Array.isArray(normalizedPatch.observability?.recentInvocations)\n ? normalizeInvocationLog(normalizedPatch.observability.recentInvocations)\n : normalizeInvocationLog(base.observability?.recentInvocations),\n traceFeedback: options.allowObservabilityPatch && Array.isArray(normalizedPatch.observability?.traceFeedback)\n ? normalizeTraceFeedbackLog(normalizedPatch.observability.traceFeedback)\n : normalizeTraceFeedbackLog(base.observability?.traceFeedback)\n }\n };\n}\n\nfunction validateAiSettingsPatch(patch = {}) {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new Error(\"AI 设置 patch 必须是对象。\");\n }\n return mergeAiSettings(buildDefaultAiSettings(), patch);\n}\n\nfunction ensureAiSettingsState(worldState = {}) {\n const current = worldState.aiSettings && typeof worldState.aiSettings === \"object\" && !Array.isArray(worldState.aiSettings)\n ? worldState.aiSettings\n : {};\n worldState.aiSettings = mergeAiSettings(buildDefaultAiSettings(), current, { allowObservabilityPatch: true });\n return worldState.aiSettings;\n}\n\nfunction applyPresetToRoute(route, settings) {\n const controls = settings.controls || {};\n const scale = Number(controls.outputScale || 1);\n const toolScale = Number(controls.toolBudgetScale ?? 1);\n const reviewOnly = REVIEW_ONLY_TASK_TYPES.includes(route.taskType);\n const toolDisabled = TOOL_DISABLED_TASK_TYPES.includes(route.taskType);\n return {\n ...route,\n maxOutputTokens: clampInt(route.maxOutputTokens * scale, route.maxOutputTokens, 128, 16000),\n toolBudget: toolDisabled ? 0 : clampInt(route.toolBudget * toolScale, route.toolBudget, 0, 20),\n mayUseTools: toolDisabled ? false : route.mayUseTools,\n mayRequestAdjudication: toolDisabled ? false : route.mayRequestAdjudication,\n reviewerOnly: reviewOnly ? true : route.reviewerOnly\n };\n}\n\nfunction applyTaskRoutePatch(route, routePatch = {}) {\n const reviewOnly = REVIEW_ONLY_TASK_TYPES.includes(route.taskType);\n const toolDisabled = TOOL_DISABLED_TASK_TYPES.includes(route.taskType);\n const next = {\n ...route,\n ...routePatch,\n toolBudget: toolDisabled ? 0 : routePatch.toolBudget ?? route.toolBudget,\n mayUseTools: toolDisabled ? false : route.mayUseTools,\n mayRequestAdjudication: toolDisabled ? false : route.mayRequestAdjudication,\n mayWriteState: false,\n mayCallServerResolvers: false,\n reviewerOnly: reviewOnly ? true : route.reviewerOnly\n };\n\n return next;\n}\n\nfunction buildRoutePolicyFromSettings(settings, env = process.env, options = {}) {\n const routePolicy = buildDefaultModelRoutePolicy(env, options);\n for (const taskType of MODEL_TASK_TYPES) {\n routePolicy.routes[taskType] = applyTaskRoutePatch(\n applyPresetToRoute(routePolicy.routes[taskType], settings),\n settings.taskRoutes?.[taskType]\n );\n }\n return validateModelRoutePolicy(routePolicy);\n}\n\nfunction normalizeGlobalSettingsRecordPayload(payload) {\n if (!payload || typeof payload !== \"object\" || Array.isArray(payload)) {\n throw createGlobalSettingsError(\"全局 AI 设置文件格式错误。\");\n }\n const rawSettings = payload.settings || payload.aiSettings || payload;\n if (!rawSettings || typeof rawSettings !== \"object\" || Array.isArray(rawSettings)) {\n throw createGlobalSettingsError(\"全局 AI 设置文件缺少 settings。\");\n }\n const settings = mergeAiSettings(buildDefaultAiSettings(), rawSettings);\n return {\n schemaVersion: cleanVisibleText(payload.schemaVersion, GLOBAL_AI_SETTINGS_SCHEMA_VERSION, 64),\n settings,\n updatedAt: cleanVisibleText(payload.updatedAt, \"\", 64) || null\n };\n}\n\nfunction readGlobalAiSettingsRecord(env = process.env, options = {}) {\n const filePath = resolveGlobalAiSettingsPath(env, options);\n if (!filePath) {\n return {\n exists: false,\n settings: null,\n updatedAt: null\n };\n }\n\n try {\n const raw = fs.readFileSync(filePath, \"utf8\");\n const parsed = JSON.parse(raw);\n return {\n exists: true,\n ...normalizeGlobalSettingsRecordPayload(parsed)\n };\n } catch (error) {\n if (error.code === \"ENOENT\") {\n return {\n exists: false,\n settings: null,\n updatedAt: null\n };\n }\n if (error.statusCode) throw error;\n throw createGlobalSettingsError(\"全局 AI 设置文件无法读取或格式错误。\");\n }\n}\n\nfunction globalSettingsState(settings, worldState = {}) {\n return {\n ...worldState,\n sessionId: worldState.sessionId || \"global-ai-settings\",\n turnCount: worldState.turnCount || 0,\n aiSettings: {\n ...settings,\n observability: worldState.aiSettings?.observability || settings.observability\n }\n };\n}\n\nfunction writeGlobalAiSettingsRecord(settings, env = process.env) {\n assertSettingsSafe(settings);\n const filePath = resolveGlobalAiSettingsPath(env, { forWrite: true });\n if (!filePath) throw createGlobalSettingsError(\"全局 AI 设置路径不可用。\");\n const updatedAt = new Date().toISOString();\n const record = {\n schemaVersion: GLOBAL_AI_SETTINGS_SCHEMA_VERSION,\n settingsSchemaVersion: settings.schemaVersion,\n updatedAt,\n settings: {\n schemaVersion: settings.schemaVersion,\n preset: settings.preset,\n controls: { ...settings.controls },\n taskRoutes: { ...(settings.taskRoutes || {}) }\n }\n };\n fs.mkdirSync(path.dirname(filePath), { recursive: true });\n const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;\n try {\n fs.writeFileSync(tempPath, `${JSON.stringify(record, null, 2)}\\n`, \"utf8\");\n fs.renameSync(tempPath, filePath);\n } catch (error) {\n try {\n fs.rmSync(tempPath, { force: true });\n } catch (cleanupError) {\n // best effort\n }\n throw error;\n }\n return record;\n}\n\nfunction buildGlobalAiSettingsPayload(env = process.env, options = {}) {\n const record = readGlobalAiSettingsRecord(env, options);\n const settings = record.exists ? record.settings : buildDefaultAiSettings();\n const routePolicy = buildRoutePolicyFromSettings(settings, env);\n const worldState = globalSettingsState(settings, options.worldState);\n const aiInvocationSummaryView = buildAiInvocationSummaryView(worldState, routePolicy, env);\n return {\n sessionId: options.worldState?.sessionId || \"global\",\n targetSessionId: options.targetSessionId || options.worldState?.sessionId || null,\n scope: \"global\",\n updatedAt: record.updatedAt,\n globalSettingsExists: record.exists,\n aiSettingsView: redactAiSettingsForClient({ ...settings, routePolicy }, env),\n aiInvocationSummaryView,\n aiControlAuditView: options.buildAiControlAuditView\n ? options.buildAiControlAuditView(worldState, { routePolicy, aiInvocationSummaryView })\n : undefined\n };\n}\n\nfunction updateGlobalAiSettings(patch = {}, env = process.env, options = {}) {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new Error(\"AI 设置 patch 必须是对象。\");\n }\n const record = readGlobalAiSettingsRecord(env, { forWrite: true });\n const worldState = globalSettingsState(record.exists ? record.settings : buildDefaultAiSettings(), options.worldState);\n const result = updateAiSettings(worldState, patch, env);\n assertRouteProvidersAvailable(result.routePolicy, env);\n const saved = writeGlobalAiSettingsRecord(result.settings, env);\n const aiInvocationSummaryView = buildAiInvocationSummaryView(worldState, result.routePolicy, env);\n return {\n sessionId: options.worldState?.sessionId || \"global\",\n targetSessionId: options.targetSessionId || options.worldState?.sessionId || null,\n scope: \"global\",\n updatedAt: saved.updatedAt,\n globalSettingsExists: true,\n settings: result.settings,\n routePolicy: result.routePolicy,\n aiSettingsView: redactAiSettingsForClient({ ...result.settings, routePolicy: result.routePolicy }, env),\n aiInvocationSummaryView,\n aiControlAuditView: options.buildAiControlAuditView\n ? options.buildAiControlAuditView(worldState, {\n routePolicy: result.routePolicy,\n aiInvocationSummaryView\n })\n : undefined\n };\n}\n\nfunction resolveAiSettingsForSession(worldState = {}, env = process.env, options = {}) {\n const globalRecord = readGlobalAiSettingsRecord(env, options);\n const settings = globalRecord.exists ? globalRecord.settings : ensureAiSettingsState(worldState);\n const routePolicy = buildRoutePolicyFromSettings(settings, env, options);\n return {\n settings,\n routePolicy,\n scope: globalRecord.exists ? \"global\" : \"session\",\n updatedAt: globalRecord.updatedAt,\n globalSettingsExists: globalRecord.exists\n };\n}\n\nfunction providerHasRequiredKeys(provider, env = process.env) {\n const normalized = normalizeProviderName(provider);\n if (normalized === \"mock\") return true;\n if (normalized === \"openai\") return Boolean(env.OPENAI_API_KEY);\n if (normalized === \"deepseek\") return Boolean(env.DEEPSEEK_API_KEY);\n if (normalized === \"mimo\") return Boolean(env.MIMO_API_KEY);\n if (normalized === \"mimo-deepseek\") return Boolean(env.MIMO_API_KEY && env.DEEPSEEK_API_KEY);\n if (normalized === \"anthropic\") return Boolean(env.ANTHROPIC_API_KEY);\n return false;\n}\n\nfunction assertRouteProvidersAvailable(routePolicy, env = process.env) {\n const unavailable = MODEL_TASK_TYPES\n .map((taskType) => resolveModelForTask(taskType, routePolicy))\n .filter((route) => !providerHasRequiredKeys(route.provider, env));\n if (!unavailable.length) return;\n const summary = unavailable\n .map((route) => `${AI_TASK_LABELS[route.taskType] || route.taskType}:${route.provider}`)\n .join(\",\");\n throw new Error(`全局 AI 设置不能保存缺少 key 的 provider:${summary}。`);\n}\n\nfunction redactModelName(model) {\n return cleanVisibleText(model, \"[redacted-model]\", 96);\n}\n\nfunction routeEffectiveStatus(route, env = process.env) {\n if (!providerHasRequiredKeys(route.provider, env)) return \"missing_provider_key\";\n if (route.reviewerOnly) return \"review_only\";\n if (!route.mayUseTools && route.toolBudget === 0) return \"no_tool\";\n return \"active\";\n}\n\nfunction redactAiSettingsForClient(settings, env = process.env) {\n const resolved = settings.routePolicy\n ? settings\n : { ...settings, routePolicy: buildRoutePolicyFromSettings(settings, env) };\n const routePolicy = resolved.routePolicy;\n const preset = AI_SETTING_PRESETS[resolved.preset] || AI_SETTING_PRESETS.balanced;\n\n return {\n schemaVersion: AI_SETTINGS_SCHEMA_VERSION,\n preset: resolved.preset,\n presetLabel: preset.label,\n presets: Object.values(AI_SETTING_PRESETS).map((item) => ({\n id: item.id,\n label: item.label\n })),\n providerOptions: SUPPORTED_PROVIDERS.map((provider) => ({\n provider,\n available: providerHasRequiredKeys(provider, env),\n requiresKey: provider !== \"mock\"\n })),\n controls: { ...resolved.controls },\n taskRoutes: MODEL_TASK_TYPES.map((taskType) => {\n const route = resolveModelForTask(taskType, routePolicy);\n const providerAvailable = providerHasRequiredKeys(route.provider, env);\n return {\n taskType,\n label: AI_TASK_LABELS[taskType] || taskType,\n purpose: cleanVisibleText(route.purpose, AI_TASK_LABELS[taskType] || taskType, 160),\n provider: route.provider,\n providerAvailable,\n requiresKey: route.provider !== \"mock\",\n effectiveStatus: routeEffectiveStatus(route, env),\n model: redactModelName(route.model),\n maxOutputTokens: route.maxOutputTokens,\n toolBudget: route.toolBudget,\n temperature: route.temperature,\n reviewerOnly: Boolean(route.reviewerOnly),\n mayUseTools: Boolean(route.mayUseTools),\n mayRequestAdjudication: Boolean(route.mayRequestAdjudication)\n };\n }),\n safeguards: {\n serverOwnsState: true,\n noHiddenRawAccess: true,\n noDirectDatabaseWrites: true,\n reviewOnlyTasks: [...REVIEW_ONLY_TASK_TYPES]\n }\n };\n}\n\nfunction buildRouteCostSummary(routePolicy, env = process.env) {\n const policy = validateModelRoutePolicy(routePolicy);\n const routes = MODEL_TASK_TYPES.map((taskType) => resolveModelForTask(taskType, policy));\n return {\n taskCount: routes.length,\n maxOutputTokens: routes.reduce((sum, route) => sum + route.maxOutputTokens, 0),\n maxToolCalls: routes.reduce((sum, route) => sum + route.toolBudget, 0),\n reviewerOnlyTasks: routes.filter((route) => route.reviewerOnly).map((route) => route.taskType),\n providers: [...new Set(routes.map((route) => route.provider))].sort(),\n unavailableProviders: [...new Set(routes\n .filter((route) => !providerHasRequiredKeys(route.provider, env))\n .map((route) => route.provider))]\n .sort()\n };\n}\n\nfunction recordAiInvocation(worldState = {}, invocation = {}) {\n const settings = ensureAiSettingsState(worldState);\n const route = invocation.route || {};\n const item = {\n id: cleanVisibleText(invocation.id, `ai-invocation:${Date.now()}`, 80),\n taskType: cleanVisibleText(invocation.taskType || route.taskType, \"narrator\", 40),\n provider: normalizeProviderName(invocation.provider || route.provider || \"mock\"),\n model: redactModelName(invocation.model || route.model || \"mock\"),\n status: cleanVisibleText(invocation.status, \"completed\", 32),\n durationMs: clampInt(invocation.durationMs, 0, 0, 300000),\n maxOutputTokens: clampInt(invocation.maxOutputTokens ?? route.maxOutputTokens, 0, 0, 16000),\n toolCallCount: clampInt(invocation.toolCallCount, 0, 0, 100),\n rejectedToolCallCount: clampInt(invocation.rejectedToolCallCount, 0, 0, 100),\n rejectionReasons: Array.isArray(invocation.rejectionReasons)\n ? invocation.rejectionReasons.map((reason) => cleanVisibleText(reason, \"\", 80)).filter(Boolean).slice(0, 4)\n : [],\n recordedTurn: clampInt(worldState.turnCount, 0, 0, Number.MAX_SAFE_INTEGER)\n };\n settings.observability.recentInvocations = [\n ...(settings.observability.recentInvocations || []),\n item\n ].slice(-AI_INVOCATION_LOG_LIMIT);\n return item;\n}\n\nfunction buildAiInvocationSummaryView(worldState = {}, routePolicy = null, env = process.env) {\n const { settings, routePolicy: resolvedPolicy } = routePolicy\n ? { settings: ensureAiSettingsState(worldState), routePolicy }\n : resolveAiSettingsForSession(worldState, env);\n const recentInvocations = Array.isArray(settings.observability?.recentInvocations)\n ? settings.observability.recentInvocations.slice(-AI_INVOCATION_LOG_LIMIT)\n : [];\n\n return {\n schemaVersion: AI_SETTINGS_SCHEMA_VERSION,\n generatedAtTurn: worldState.turnCount || 0,\n routeCostSummary: buildRouteCostSummary(resolvedPolicy, env),\n toolCallSummary: {\n recentToolCalls: recentInvocations.reduce((sum, item) => sum + (item.toolCallCount || 0), 0),\n recentRejectedToolCalls: recentInvocations.reduce((sum, item) => sum + (item.rejectedToolCallCount || 0), 0),\n rejectionReasons: [...new Set(recentInvocations.flatMap((item) => item.rejectionReasons || []))].slice(0, 8)\n },\n recentInvocations: recentInvocations.map((item) => ({\n taskType: item.taskType,\n label: AI_TASK_LABELS[item.taskType] || item.taskType,\n provider: item.provider,\n model: redactModelName(item.model),\n status: item.status,\n durationMs: item.durationMs,\n maxOutputTokens: item.maxOutputTokens,\n toolCallCount: item.toolCallCount,\n rejectedToolCallCount: item.rejectedToolCallCount,\n recordedTurn: item.recordedTurn\n }))\n };\n}\n\nfunction updateAiSettings(worldState = {}, patch = {}, env = process.env) {\n const current = ensureAiSettingsState(worldState);\n worldState.aiSettings = mergeAiSettings(current, patch);\n const routePolicy = buildRoutePolicyFromSettings(worldState.aiSettings, env);\n return {\n settings: worldState.aiSettings,\n routePolicy,\n aiSettingsView: redactAiSettingsForClient({ ...worldState.aiSettings, routePolicy }, env),\n aiInvocationSummaryView: buildAiInvocationSummaryView(worldState, routePolicy, env)\n };\n}\n\nmodule.exports = {\n AI_SETTINGS_SCHEMA_VERSION,\n DEFAULT_GLOBAL_AI_SETTINGS_PATH,\n GLOBAL_AI_SETTINGS_SCHEMA_VERSION,\n buildAiInvocationSummaryView,\n buildDefaultAiSettings,\n buildGlobalAiSettingsPayload,\n ensureAiSettingsState,\n providerHasRequiredKeys,\n recordAiInvocation,\n redactAiSettingsForClient,\n readGlobalAiSettingsRecord,\n resolveAiSettingsForSession,\n updateGlobalAiSettings,\n updateAiSettings,\n validateAiSettingsPatch\n};\n"}}, "src/game/npcMind.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1786068267.8474617, "n": 15211, "h": "047d169fb97e8146", "c": "const {\n NPC_MIND_LIMITS,\n NPC_MIND_PROPOSAL_TYPES,\n NPC_MIND_SCHEMA_VERSION,\n NPC_MIND_SCORE_WEIGHTS\n} = require(\"./npcMindConfig\");\nconst { buildNpcAiActorProfile, summarizeAiActorProfileForPrompt } = require(\"./aiActorProfiles\");\nconst { buildActiveNpcRequestView } = require(\"./activeRequests\");\nconst { applyRelationshipChanges, normalizeRelationshipLedger } = require(\"./relationships\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\nconst { buildEventArchiveView } = require(\"./eventArchive\");\n\nconst SENSITIVE_NPC_MIND_TEXT_PATTERN = /(hiddenNotes|hiddenIntent|raw[_ -]?(?:provider|audit|table|ledger|prompt)|\\b(?:provider|prompt|proposal)\\b|retrievalContext|statePatch|worldState|prompt_retrieval_index|event_archive_index|world_sessions|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:\\\\[^\\s\"'<>]+|(?:file:\\/\\/)?(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = NPC_MIND_LIMITS.maxTextLength) {\n if (typeof value !== \"string\") return fallback;\n const trimmed = value.trim().replace(/\\s+/g, \" \");\n if (!trimmed || SENSITIVE_NPC_MIND_TEXT_PATTERN.test(trimmed)) return fallback;\n return trimmed.slice(0, maxLength);\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction cleanTextList(values, limit = 8, maxLength = NPC_MIND_LIMITS.maxTextLength) {\n const result = [];\n for (const value of Array.isArray(values) ? values : []) {\n const text = cleanText(value, \"\", maxLength);\n if (!text || result.includes(text)) continue;\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction clampNumber(value, min, max, fallback) {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(min, Math.min(max, Math.round(parsed)));\n}\n\nfunction normalizeProposalType(value, fallback = \"memory\") {\n const text = cleanText(value, \"\", 40);\n if (NPC_MIND_PROPOSAL_TYPES.includes(text)) return text;\n return NPC_MIND_PROPOSAL_TYPES.includes(fallback) ? fallback : \"memory\";\n}\n\nfunction currentTurn(worldState = {}) {\n return clampNumber(worldState.turnCount, 0, Number.MAX_SAFE_INTEGER, 0);\n}\n\nfunction sanitizeActorSummaryForNpcMind(actorProfile = {}) {\n const summary = summarizeAiActorProfileForPrompt(actorProfile);\n if (!summary) return null;\n return {\n actorId: cleanText(summary.actorId, \"\", 96),\n actorType: cleanText(summary.actorType, \"\", 40),\n authorityTier: cleanText(summary.authorityTier, \"\", 8),\n role: cleanText(summary.role, \"\", 80),\n allowedToolGroups: cleanTextList(summary.allowedToolGroups, 12, 64),\n visibilityDomains: cleanTextList(summary.visibilityDomains, 12, 64),\n currentGoals: cleanTextList(summary.currentGoals, 5, 120),\n boundaryStatement: cleanText(summary.boundaryStatement, \"\", 180)\n };\n}\n\nfunction relationshipEntryForNpc(worldState, npcId) {\n const ledger = normalizeRelationshipLedger(worldState.relationshipLedger, worldState);\n return ledger.characters[npcId] || null;\n}\n\nfunction calculateNpcSalience(worldState, npc = {}) {\n const id = cleanId(npc.id, \"\");\n const entry = relationshipEntryForNpc(worldState, id);\n const activeRequest = buildActiveNpcRequestView(worldState);\n const relationship = Math.abs(clampNumber(entry?.relationship, -100, 100, 0));\n const resentment = clampNumber(entry?.resentment ?? npc.resentmentRisk, 0, 100, 0);\n const influence = clampNumber(npc.influence, 0, 100, 20);\n const requestScore = activeRequest?.targetType === \"character\" && activeRequest.targetId === id ? 100 : 0;\n const turnGap = Math.max(0, currentTurn(worldState) - clampNumber(entry?.lastUpdatedTurn ?? npc.lastUpdatedTurn, 0, Number.MAX_SAFE_INTEGER, 0));\n const recentTurn = Math.max(0, 100 - turnGap * 12);\n const score = Math.round(\n relationship * NPC_MIND_SCORE_WEIGHTS.relationship +\n resentment * NPC_MIND_SCORE_WEIGHTS.resentment +\n influence * NPC_MIND_SCORE_WEIGHTS.influence +\n requestScore * NPC_MIND_SCORE_WEIGHTS.activeRequest +\n recentTurn * NPC_MIND_SCORE_WEIGHTS.recentTurn\n );\n return {\n score,\n reasons: [\n relationship >= 20 ? \"关系显著\" : \"\",\n resentment >= 20 ? \"怨望显著\" : \"\",\n influence >= 50 ? \"影响较高\" : \"\",\n requestScore ? \"当前请托相关\" : \"\",\n recentTurn >= 80 ? \"近期互动\" : \"\"\n ].filter(Boolean)\n };\n}\n\nfunction rankNpcSalience(worldState = {}, options = {}) {\n const view = buildWorldPeopleView(worldState);\n const limit = clampNumber(options.limit, 1, 50, NPC_MIND_LIMITS.maxLlmNpcPerTenDay);\n return (view.npcs || [])\n .filter((npc) => npc && npc.alive !== false && npc.knownToPlayer !== false && npc.visibility !== \"hidden\")\n .map((npc) => {\n const salience = calculateNpcSalience(worldState, npc);\n return {\n npcId: cleanId(npc.id, \"\"),\n name: cleanText(npc.name, \"可见人物\", 80),\n role: cleanText(npc.rankLabel, \"可见人物\", 80),\n salienceScore: salience.score,\n salienceReasons: salience.reasons,\n publicSummary: cleanText(npc.publicSummary, \"\", 160),\n currentGoal: cleanText(npc.currentGoal, \"\", 120)\n };\n })\n .filter((row) => row.npcId)\n .sort((a, b) => b.salienceScore - a.salienceScore || a.npcId.localeCompare(b.npcId))\n .slice(0, limit);\n}\n\nfunction buildNpcMindPromptContext(worldState = {}, actorProfile = {}, options = {}) {\n const maxItems = clampNumber(options.maxItems, 1, 12, NPC_MIND_LIMITS.maxPromptContextItems);\n const safeActorProfile = isPlainObject(actorProfile) ? actorProfile : {};\n const actorId = cleanText(safeActorProfile.actorId, \"\", 96);\n if (!actorId.startsWith(\"npc:\")) return null;\n\n const npcId = cleanId(actorId.replace(/^npc:/, \"\"), \"\");\n const people = buildWorldPeopleView(worldState);\n const npc = (people.npcs || []).find((row) => row.id === npcId) || {};\n if (!npc.id) return null;\n\n const relationship = (people.relationships || []).find((row) => row.targetType === \"npc\" && row.targetId === npcId);\n const activeRequest = buildActiveNpcRequestView(worldState);\n const archive = buildEventArchiveView(worldState, { pageSize: maxItems });\n const recentEvents = (archive.items || [])\n .map((item) => cleanText(`${item.title || item.kind}:${item.summary || \"\"}`, \"\", 140))\n .filter(Boolean)\n .slice(0, maxItems);\n\n return {\n schemaVersion: NPC_MIND_SCHEMA_VERSION,\n actor: sanitizeActorSummaryForNpcMind(safeActorProfile),\n npc: {\n id: cleanId(npc.id, npcId),\n name: cleanText(npc.name, safeActorProfile.displayName || \"可见人物\", 80),\n publicSummary: cleanText(npc.publicSummary, \"\", 160),\n currentGoal: cleanText(npc.currentGoal, \"\", 120),\n influence: clampNumber(npc.influence, 0, 100, 0),\n resentmentRisk: clampNumber(npc.resentmentRisk, 0, 100, 0)\n },\n relationship: relationship\n ? {\n relationship: clampNumber(relationship.relationship, -100, 100, 0),\n trust: clampNumber(relationship.trust, 0, 100, 0),\n resentment: clampNumber(relationship.resentment, 0, 100, 0),\n stance: cleanText(relationship.stance, \"\", 80),\n recentIntent: cleanText(relationship.recentIntent, \"\", 120)\n }\n : null,\n activeRequest: activeRequest?.targetId === npcId\n ? {\n id: cleanId(activeRequest.id, \"\"),\n title: cleanText(activeRequest.title, \"\", 100),\n ask: cleanText(activeRequest.ask, \"\", 140),\n turnsRemaining: clampNumber(activeRequest.turnsRemaining, 0, 99, 0)\n }\n : null,\n recentEvents\n };\n}\n\nfunction classifyNpcIntent(context = {}) {\n const resentment = context.relationship?.resentment ?? context.npc?.resentmentRisk ?? 0;\n const relationship = context.relationship?.relationship ?? 0;\n if (context.activeRequest) return \"request\";\n if (resentment >= 35) return \"obstruct\";\n if (relationship >= 25 || context.relationship?.trust >= 65) return \"assist\";\n if ((context.npc?.influence || 0) >= 55) return \"warn\";\n return \"memory\";\n}\n\nfunction buildHeuristicProposal(worldState, actorProfile, context, options = {}) {\n const safeActorProfile = isPlainObject(actorProfile) ? actorProfile : {};\n const safeContext = isPlainObject(context) ? context : {};\n const intentType = normalizeProposalType(options.intentType, classifyNpcIntent(safeContext));\n const npcName = cleanText(safeContext.npc?.name || safeActorProfile.displayName, \"此人\", 80);\n const base = {\n schemaVersion: NPC_MIND_SCHEMA_VERSION,\n proposalId: `npc-mind:${cleanId(safeContext.npc?.id || safeActorProfile.actorId, \"npc\")}:${currentTurn(worldState)}`,\n actorId: cleanText(safeActorProfile.actorId, \"\", 96),\n npcId: cleanId(safeContext.npc?.id, \"\"),\n intentType,\n confidence: 0.62,\n publicSummary: \"\",\n relationshipDelta: 0,\n resentmentDelta: 0,\n events: [],\n memoryCandidates: [],\n toolCalls: []\n };\n\n if (intentType === \"request\") {\n base.publicSummary = `${npcName}仍盼你回应眼前请托。`;\n base.relationshipDelta = 0;\n base.resentmentDelta = 1;\n base.events.push(`[NPC心念] ${npcName}惦记着未了的人情。`);\n base.memoryCandidates.push(`${npcName}记得当前请托尚未收束。`);\n } else if (intentType === \"obstruct\") {\n base.publicSummary = `${npcName}对你心存芥蒂,暗中观望。`;\n base.relationshipDelta = -1;\n base.resentmentDelta = 2;\n base.events.push(`[NPC心念] ${npcName}因旧怨而多留一分戒心。`);\n base.memoryCandidates.push(`${npcName}对玩家的怨望仍在累积。`);\n } else if (intentType === \"assist\") {\n base.publicSummary = `${npcName}愿在小事上助你一臂之力。`;\n base.relationshipDelta = 2;\n base.resentmentDelta = -1;\n base.events.push(`[NPC心念] ${npcName}念及旧情,态度稍缓。`);\n base.memoryCandidates.push(`${npcName}把玩家视为可互相照应之人。`);\n } else if (intentType === \"warn\") {\n base.publicSummary = `${npcName}留意到局势压力,准备递来提醒。`;\n base.relationshipDelta = 1;\n base.resentmentDelta = 0;\n base.events.push(`[NPC心念] ${npcName}留意近日风声,或有提醒。`);\n base.memoryCandidates.push(`${npcName}关注近期局势与玩家处境。`);\n } else {\n base.publicSummary = `${npcName}把近日互动记入心中。`;\n base.relationshipDelta = 0;\n base.resentmentDelta = 0;\n base.events.push(`[NPC心念] ${npcName}默默记下近日往来。`);\n base.memoryCandidates.push(`${npcName}形成一条新的公开印象。`);\n }\n\n return sanitizeNpcMindProposal(base);\n}\n\nasync function generateNpcMindProposal(worldState = {}, actorProfile = {}, aiClient = null, options = {}) {\n const context = options.context || buildNpcMindPromptContext(worldState, actorProfile, options);\n const safeActorProfile = isPlainObject(actorProfile) ? actorProfile : {};\n const expectedActorId = cleanText(context?.actor?.actorId || safeActorProfile.actorId, \"\", 96);\n const expectedNpcId = cleanId(context?.npc?.id, \"\");\n if (!context) {\n return sanitizeNpcMindProposal({\n actorId: isPlainObject(actorProfile) ? actorProfile.actorId : \"\",\n intentType: \"memory\",\n publicSummary: \"NPC 心念候选。\"\n });\n }\n if (aiClient && typeof aiClient.generateNpcMindProposal === \"function\" && options.allowAi === true) {\n const proposal = await aiClient.generateNpcMindProposal(context, actorProfile, options);\n return sanitizeNpcMindProposal(proposal, { actorId: expectedActorId, npcId: expectedNpcId });\n }\n return buildHeuristicProposal(worldState, actorProfile, context, options);\n}\n\nfunction sanitizeNpcMindProposal(proposal = {}, constraints = {}) {\n const source = isPlainObject(proposal) ? proposal : {};\n const expectedActorId = cleanText(constraints.actorId, \"\", 96);\n const expectedNpcId = cleanId(constraints.npcId, \"\");\n return {\n schemaVersion: NPC_MIND_SCHEMA_VERSION,\n proposalId: cleanId(source.proposalId, `npc-mind:${Date.now()}`),\n actorId: expectedActorId || cleanText(source.actorId, \"\", 96),\n npcId: expectedNpcId || cleanId(source.npcId, \"\"),\n intentType: normalizeProposalType(source.intentType, \"memory\"),\n confidence: Math.max(0, Math.min(1, Number(source.confidence) || 0.5)),\n publicSummary: cleanText(source.publicSummary, \"NPC 心念候选。\", 160),\n relationshipDelta: clampNumber(source.relationshipDelta, -3, 3, 0),\n resentmentDelta: clampNumber(source.resentmentDelta, -3, 3, 0),\n events: (Array.isArray(source.events) ? source.events : [])\n .map((item) => cleanText(item, \"\", 140))\n .filter(Boolean)\n .slice(0, NPC_MIND_LIMITS.maxProposalEvents),\n memoryCandidates: (Array.isArray(source.memoryCandidates) ? source.memoryCandidates : [])\n .map((item) => cleanText(item, \"\", 140))\n .filter(Boolean)\n .slice(0, NPC_MIND_LIMITS.maxMemoryCandidates),\n toolCalls: []\n };\n}\n\nfunction applyNpcMindProposal(worldState = {}, proposal = {}, auditContext = {}) {\n const safeProposal = sanitizeNpcMindProposal(proposal);\n const events = safeProposal.events.length\n ? safeProposal.events\n : [`[NPC心念] ${safeProposal.publicSummary}`];\n const relationshipChanges = safeProposal.npcId\n ? applyRelationshipChanges(worldState, [{\n targetType: \"character\",\n targetId: safeProposal.npcId,\n relationshipDelta: safeProposal.relationshipDelta,\n resentmentDelta: safeProposal.resentmentDelta,\n stance: safeProposal.intentType,\n recentIntent: safeProposal.publicSummary,\n reason: \"NPC mind proposal resolved by server.\"\n }])\n : [];\n\n const memoryCandidates = safeProposal.memoryCandidates.map((text, index) => ({\n id: `${safeProposal.proposalId}:memory:${index + 1}`,\n actorId: safeProposal.actorId,\n npcId: safeProposal.npcId,\n visibility: \"player_visible\",\n confidence: safeProposal.confidence,\n summary: text,\n source: \"npc_mind_heuristic\",\n turnCount: currentTurn(worldState)\n }));\n\n if (Array.isArray(auditContext.npcMindRecords)) {\n auditContext.npcMindRecords.push({\n proposalId: safeProposal.proposalId,\n actorId: safeProposal.actorId,\n npcId: safeProposal.npcId,\n status: \"applied\",\n publicSummary: safeProposal.publicSummary,\n relationshipChangeCount: relationshipChanges.length,\n memoryCandidateCount: memoryCandidates.length\n });\n }\n\n return {\n proposal: safeProposal,\n events,\n relationshipChanges,\n memoryCandidates\n };\n}\n\nfunction runBackgroundNpcHeuristic(worldState = {}, options = {}) {\n const limit = clampNumber(options.limit, 1, 20, NPC_MIND_LIMITS.maxBackgroundNpcPerTenDay);\n return rankNpcSalience(worldState, { limit })\n .filter((row) => row.salienceScore < 45)\n .map((row) => ({\n npcId: row.npcId,\n publicSummary: `${row.name}维持原有往来,暂不触发大事。`,\n memoryCandidate: `${row.name}延续既有立场。`\n }));\n}\n\nfunction buildNpcMindActorProfile(worldState, npcId, options = {}) {\n return buildNpcAiActorProfile(worldState, npcId, {\n allowUnknown: options.allowUnknown === true\n });\n}\n\nmodule.exports = {\n applyNpcMindProposal,\n buildHeuristicProposal,\n buildNpcMindActorProfile,\n buildNpcMindPromptContext,\n generateNpcMindProposal,\n rankNpcSalience,\n runBackgroundNpcHeuristic,\n sanitizeNpcMindProposal\n};\n"}}, "src/game/aiActorProfiles.js": {"6d2535f4de1a37404f0290337a400a7edaad2d80": {"t": 1786068268.458742, "n": 18983, "h": "7cb1c3dbb83f430d", "c": "const {\n ACTOR_TYPE_TEMPLATES,\n AI_ACTOR_PROFILE_SCHEMA_VERSION,\n AUTHORITY_TIERS,\n BUDGET_PRESETS,\n TOOL_GROUPS,\n VISIBILITY_PRESETS,\n getActorTypeTemplate\n} = require(\"./aiActorProfileConfig\");\nconst { buildWorldPeopleView } = require(\"./worldPeople\");\nconst { buildOfficialPostingsView } = require(\"./officialPostings\");\nconst { buildOfficialCareerView } = require(\"./officialCareer\");\nconst { getBureau, getOffice, inferOfficeByTitle } = require(\"./officialCatalog\");\nconst { validateToolDefinition } = require(\"../ai/toolSchemas\");\n\nconst MAX_TEXT_LENGTH = 160;\nconst ROLE_TO_ACTOR_TYPE = Object.freeze({\n scholar: \"scholar\",\n emperor: \"emperor\",\n minister: \"minister\",\n general: \"general\",\n magistrate: \"magistrate\"\n});\n\nconst SENSITIVE_ACTOR_TEXT_PATTERN = /(hiddenNotes|hiddenIntent|raw[_ -]?(?:provider|audit|table|ledger|prompt)|provider proposal|retrievalContext|statePatch|worldState|prompt_retrieval_index|event_archive_index|world_sessions|api[_ -]?key|OPENAI_API_KEY|DEEPSEEK_API_KEY|MIMO_API_KEY|ANTHROPIC_API_KEY|data[\\\\/](?:sessions|audit)|ai_change_proposals|event_log|sqlite|sk-[A-Za-z0-9_-]{6,}|tp-[A-Za-z0-9_-]{6,}|[A-Za-z]:\\\\[^\\s\"'<>]+|\\b(?:\\/Users|\\/home|\\/tmp|\\/var|\\/mnt|\\/opt)\\/[^\\s\"'<>]+)/i;\n\nfunction isPlainObject(value) {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction cleanText(value, fallback = \"\", maxLength = MAX_TEXT_LENGTH) {\n if (typeof value !== \"string\") return fallback;\n const trimmed = value.trim().replace(/\\s+/g, \" \");\n if (!trimmed || SENSITIVE_ACTOR_TEXT_PATTERN.test(trimmed)) return fallback;\n return trimmed.slice(0, maxLength);\n}\n\nfunction cleanId(value, fallback = \"\") {\n const text = cleanText(value, fallback, 96);\n return text.replace(/[^A-Za-z0-9_.:-]+/g, \"-\").replace(/^-+|-+$/g, \"\") || fallback;\n}\n\nfunction unique(values, limit = 20) {\n const seen = new Set();\n const result = [];\n for (const value of values || []) {\n const text = cleanText(value, \"\", 96);\n if (!text || seen.has(text)) continue;\n seen.add(text);\n result.push(text);\n if (result.length >= limit) break;\n }\n return result;\n}\n\nfunction cloneJson(value) {\n return JSON.parse(JSON.stringify(value));\n}\n\nfunction normalizeToolGroups(groups, fallback = []) {\n const normalized = unique(groups, 32)\n .filter((group) => Object.prototype.hasOwnProperty.call(TOOL_GROUPS, group))\n .filter((group, index, source) => source.indexOf(group) === index);\n return normalized.length ? normalized : unique(fallback, 32);\n}\n\nfunction tierDefaults(authorityTier) {\n return AUTHORITY_TIERS[authorityTier] || AUTHORITY_TIERS.T1;\n}\n\nfunction mergeToolGroups(template, tier) {\n const tierAllowed = tierDefaults(tier).defaultToolGroups || [];\n const templateAllowed = Array.isArray(template.allowedToolGroups) ? template.allowedToolGroups : [];\n const tierForbidden = new Set(tierDefaults(tier).forbiddenToolGroups || []);\n const templateForbidden = new Set(template.forbiddenToolGroups || []);\n const source = templateAllowed.length ? templateAllowed : tierAllowed;\n return normalizeToolGroups(source).filter((group) => !tierForbidden.has(group) && !templateForbidden.has(group));\n}\n\nfunction budgetForTier(authorityTier) {\n const presetKey = tierDefaults(authorityTier).budgetPreset || \"low\";\n return {\n preset: presetKey,\n ...cloneJson(BUDGET_PRESETS[presetKey] || BUDGET_PRESETS.low)\n };\n}\n\nfunction visibilityForTier(authorityTier) {\n const presetKey = tierDefaults(authorityTier).visibilityPreset || \"relationship_local\";\n return {\n preset: presetKey,\n ...cloneJson(VISIBILITY_PRESETS[presetKey] || VISIBILITY_PRESETS.relationship_local)\n };\n}\n\nfunction inferActorTypeForPlayer(worldState = {}) {\n const player = worldState.player || {};\n if (player.role && player.role !== \"official\") {\n return ROLE_TO_ACTOR_TYPE[player.role] || \"scholar\";\n }\n\n const officeTitle = cleanText(player.officeTitle || player.position || worldState.officialCareer?.currentPosting, \"\");\n const office = officeTitle ? inferOfficeByTitle(officeTitle) : null;\n if (office?.bureauId === \"prefecture_county\" || office?.track === \"local\") return \"magistrate\";\n if (office?.bureauId === \"ministry_war\" || /将|总兵|提督|巡检/.test(officeTitle)) return \"general\";\n if (office?.bureauId === \"censorate\" || /御史|给事|谏/.test(officeTitle)) return \"censor\";\n return \"minister\";\n}\n\nfunction inferNpcActorType(npc = {}) {\n const text = [\n cleanText(npc.rankLabel, \"\"),\n cleanText(npc.publicSummary, \"\"),\n cleanText(npc.currentGoal, \"\")\n ].join(\" \");\n if (/塾师|山长|先生|老师|师/.test(text)) return \"teacher\";\n if (/考官|房官|同考官|主考|读卷/.test(text)) return \"examiner\";\n if (/县丞|书办|胥吏|吏/.test(text)) return \"clerk\";\n if (/知县|知府|地方官|巡检/.test(text)) return \"magistrate\";\n if (/将|参将|总兵|军/.test(text)) return \"general\";\n if (/御史|给事|谏/.test(text)) return \"censor\";\n if (/尚书|侍郎|部院|堂官|阁/.test(text)) return \"minister\";\n return \"gentry\";\n}\n\nfunction inferOfficeActorType(row = {}) {\n const bureauId = cleanId(row.bureauId, \"\");\n const officeTitle = cleanText(row.officeTitle || row.title || row.name, \"\");\n if (bureauId === \"prefecture_county\" || /知县|知府|地方/.test(officeTitle)) return \"magistrate\";\n if (bureauId === \"ministry_war\" || /将|军|兵部|总兵/.test(officeTitle)) return \"general\";\n if (bureauId === \"censorate\" || /御史|给事|谏/.test(officeTitle)) return \"censor\";\n if (/皇帝|御前|内廷|摄政/.test(officeTitle)) return \"emperor\";\n return \"minister\";\n}\n\nfunction buildBaseProfile(actorType, source = {}) {\n const template = getActorTypeTemplate(actorType);\n const authorityTier = cleanText(source.authorityTier, template.authorityTier || \"T1\", 4);\n const allowedToolGroups = normalizeToolGroups(source.allowedToolGroups, mergeToolGroups(template, authorityTier));\n const forbiddenToolGroups = normalizeToolGroups([\n ...(tierDefaults(authorityTier).forbiddenToolGroups || []),\n ...(template.forbiddenToolGroups || []),\n ...(source.forbiddenToolGroups || [])\n ], []);\n\n return {\n schemaVersion: AI_ACTOR_PROFILE_SCHEMA_VERSION,\n actorId: cleanId(source.actorId, `${actorType}:unknown`),\n actorType,\n label: cleanText(source.label, template.label || actorType, 80),\n displayName: cleanText(source.displayName, source.label || template.label || actorType, 80),\n authorityTier,\n authorityLabel: tierDefaults(authorityTier).label,\n role: cleanText(source.role, \"\", 80),\n officeId: cleanId(source.officeId, \"\"),\n bureauId: cleanId(source.bureauId, \"\"),\n jurisdictionRefs: unique(source.jurisdictionRefs, 12),\n allowedToolGroups,\n forbiddenToolGroups,\n visibilityProfile: visibilityForTier(authorityTier),\n budgetPolicy: budgetForTier(authorityTier),\n modelPolicy: cleanText(source.modelPolicy, template.modelPolicy || \"mock\", 64),\n ideologyTags: unique(source.ideologyTags || [], 8),\n publicMemoryRefs: unique(source.publicMemoryRefs || [], 8),\n knownRefs: unique(source.knownRefs || [], 14),\n currentGoals: unique(source.currentGoals || template.defaultGoals || [], 8),\n boundaryStatement: cleanText(\n source.boundaryStatement,\n `${template.label || actorType}只能请求身份可见工具;服务器负责裁决和落库。`,\n 180\n )\n };\n}\n\nfunction playerPostingContext(worldState = {}) {\n const view = buildOfficialPostingsView(worldState);\n const posting = (view.postings || []).find((row) => row.id === \"posting-player-current\" || row.holderType === \"player\");\n const office = posting?.officeId ? getOffice(posting.officeId) : inferOfficeByTitle(worldState.player?.officeTitle || worldState.player?.position || \"\");\n const bureau = (posting?.bureauId || office?.bureauId) ? getBureau(posting?.bureauId || office?.bureauId) : null;\n return {\n view,\n posting,\n office,\n bureau,\n jurisdictionRefs: unique([\n posting?.jurisdictionId,\n posting?.cityId,\n posting?.regionId,\n posting?.countryId,\n worldState.player?.countyName ? `county:${worldState.player.countyName}` : \"\"\n ], 8)\n };\n}\n\nfunction buildPlayerAiActorProfile(worldState = {}) {\n const player = worldState.player || {};\n const actorType = inferActorTypeForPlayer(worldState);\n const template = getActorTypeTemplate(actorType);\n const postingContext = playerPostingContext(worldState);\n const career = buildOfficialCareerView(worldState);\n const nativePlace = cleanText(player.nativePlace || worldState.setup?.nativePlace, \"\", 80);\n const officeTitle = cleanText(player.officeTitle || player.position || career.currentPosting, \"\", 80);\n\n return buildBaseProfile(actorType, {\n actorId: `player:${cleanId(player.id, \"P1\")}`,\n label: template.label,\n displayName: cleanText(player.name, \"玩家\", 80),\n role: cleanText(player.roleLabel || player.role, \"\", 80),\n officeId: postingContext.posting?.officeId || postingContext.office?.id || \"\",\n bureauId: postingContext.posting?.bureauId || postingContext.bureau?.id || \"\",\n jurisdictionRefs: postingContext.jurisdictionRefs,\n currentGoals: [\n ...(template.defaultGoals || []),\n officeTitle ? `当前名位:${officeTitle}` : \"\",\n nativePlace ? `公开籍贯:${nativePlace}` : \"\"\n ],\n publicMemoryRefs: unique([\n player.examRank ? `examRank:${player.examRank}` : \"\",\n player.palaceRank ? `palaceRank:${player.palaceRank}` : \"\",\n officeTitle ? `office:${officeTitle}` : \"\",\n nativePlace ? `nativePlace:${nativePlace}` : \"\"\n ], 8),\n knownRefs: unique([\n postingContext.posting?.id ? `officialPostingsView:${postingContext.posting.id}` : \"\",\n postingContext.bureau?.id ? `officialCatalog.bureau:${postingContext.bureau.id}` : \"\",\n postingContext.office?.id ? `officialCatalog.office:${postingContext.office.id}` : \"\"\n ], 8),\n boundaryStatement: \"玩家 actor 只能按当前身份请求可见工具;考试晋级、官职任免、数据库写入和隐藏事实仍由服务器裁决。\"\n });\n}\n\nfunction findNpcRow(worldState = {}, npcRef = {}) {\n const view = buildWorldPeopleView(worldState);\n const refId = cleanId(typeof npcRef === \"string\" ? npcRef : npcRef.id || npcRef.npcId || npcRef.targetId, \"\");\n const refName = cleanText(typeof npcRef === \"object\" ? npcRef.name : \"\", \"\", 80);\n return (view.npcs || []).find((npc) => (\n (refId && npc.id === refId) ||\n (refName && npc.name === refName)\n )) || null;\n}\n\nfunction buildNpcAiActorProfile(worldState = {}, npcRef = {}, options = {}) {\n const npc = findNpcRow(worldState, npcRef);\n if (!npc && options.allowUnknown !== true) return null;\n const actorType = cleanText(options.actorType, npc ? inferNpcActorType(npc) : \"gentry\", 40);\n const template = getActorTypeTemplate(actorType);\n const npcId = cleanId(npc?.id || (typeof npcRef === \"string\" ? npcRef : npcRef.id), \"unknown-npc\");\n\n return buildBaseProfile(actorType, {\n actorId: `npc:${npcId}`,\n label: template.label,\n displayName: cleanText(npc?.name, \"可见人物\", 80),\n role: cleanText(npc?.rankLabel, template.label, 80),\n jurisdictionRefs: unique([npc?.cityId, npc?.regionId, npc?.countryId], 8),\n ideologyTags: unique([npc?.temperament?.ambition >= 70 ? \"进取\" : \"\", npc?.temperament?.caution >= 70 ? \"谨慎\" : \"\"], 4),\n publicMemoryRefs: unique([npc?.currentGoal ? `goal:${npc.currentGoal}` : \"\"], 4),\n knownRefs: unique([npc ? `worldPeopleView.npc:${npc.id}` : \"\"], 6),\n currentGoals: unique([npc?.currentGoal, ...(template.defaultGoals || [])], 8),\n boundaryStatement: \"NPC actor 只能依据可见人物 projection 行动;不可见私档、未公开关系和原始审计不进入模型上下文。\"\n });\n}\n\nfunction findOfficeRow(worldState = {}, officeRef = {}) {\n const view = buildOfficialPostingsView(worldState);\n const refId = cleanId(typeof officeRef === \"string\" ? officeRef : officeRef.id || officeRef.officeId || officeRef.postingId || officeRef.bureauId, \"\");\n const refTitle = cleanText(typeof officeRef === \"object\" ? officeRef.title || officeRef.officeTitle || officeRef.name : \"\", \"\", 80);\n const posting = (view.postings || []).find((row) => (\n (refId && [row.id, row.officeId, row.bureauId].includes(refId)) ||\n (refTitle && row.officeTitle === refTitle)\n ));\n if (posting) return { row: posting, kind: \"posting\", view };\n const office = refId ? getOffice(refId) : inferOfficeByTitle(refTitle);\n if (office) return { row: office, kind: \"office\", view };\n const bureau = refId ? getBureau(refId) : null;\n if (bureau) return { row: bureau, kind: \"bureau\", view };\n return { row: null, kind: \"\", view };\n}\n\nfunction buildOfficeAiActorProfile(worldState = {}, officeRef = {}, options = {}) {\n const { row, kind } = findOfficeRow(worldState, officeRef);\n if (!row && options.allowUnknown !== true) return null;\n const actorType = cleanText(options.actorType, row ? inferOfficeActorType(row) : \"minister\", 40);\n const template = getActorTypeTemplate(actorType);\n const officeId = cleanId(row?.officeId || row?.id, cleanId(typeof officeRef === \"string\" ? officeRef : officeRef.officeId, \"unknown-office\"));\n const bureauId = cleanId(row?.bureauId || row?.id, \"\");\n\n return buildBaseProfile(actorType, {\n actorId: `${kind || \"office\"}:${officeId}`,\n label: template.label,\n displayName: cleanText(row?.officeTitle || row?.title || row?.name, template.label, 80),\n role: cleanText(row?.officeTitle || row?.title || row?.name, template.label, 80),\n officeId,\n bureauId,\n jurisdictionRefs: unique([\n row?.jurisdictionId,\n row?.cityId,\n row?.regionId,\n row?.countryId\n ], 8),\n knownRefs: unique([\n kind === \"posting\" ? `officialPostingsView.posting:${row.id}` : \"\",\n kind === \"office\" ? `officialCatalog.office:${row.id}` : \"\",\n kind === \"bureau\" ? `officialCatalog.bureau:${row.id}` : \"\"\n ], 8),\n currentGoals: template.defaultGoals,\n boundaryStatement: \"官署 actor 只能提出制度路径内的 proposal;任免、刑赏、战和和落库仍由服务器 resolver 裁决。\"\n });\n}\n\nfunction buildSystemEngineActorProfile(worldState = {}, engineType = \"world_tick\") {\n const type = cleanId(engineType, \"world_tick\");\n return buildBaseProfile(\"system_engine\", {\n actorId: `system:${type}`,\n displayName: \"系统世界引擎\",\n role: \"服务器调度\",\n jurisdictionRefs: unique([`dynasty:${cleanText(worldState.dynasty, \"unknown\", 40)}`], 4),\n knownRefs: unique([`turn:${worldState.turnCount || 0}`], 4),\n currentGoals: [\"压力传播\", \"长期因果\", \"安全事件候选\"],\n boundaryStatement: \"系统引擎不代表角色,不能伪装玩家可见事实;只由服务器 tick 或 resolver 调度。\"\n });\n}\n\nfunction toolListFromRegistry(toolRegistry) {\n if (Array.isArray(toolRegistry)) return toolRegistry;\n if (toolRegistry && typeof toolRegistry.listAllTools === \"function\") return toolRegistry.listAllTools();\n if (toolRegistry && typeof toolRegistry.listTools === \"function\") return toolRegistry.listTools();\n if (isPlainObject(toolRegistry)) return Object.values(toolRegistry);\n return [];\n}\n\nfunction hasToolGroupIntersection(actorGroups, toolGroups) {\n if (!Array.isArray(toolGroups) || toolGroups.length === 0) return true;\n return toolGroups.some((group) => actorGroups.includes(group));\n}\n\nfunction isToolAllowedForActor(actorProfile, toolDefinition, options = {}) {\n if (!actorProfile || !toolDefinition || typeof toolDefinition.name !== \"string\") return false;\n if (toolDefinition.name.startsWith(\"server.\")) return false;\n validateToolDefinition(toolDefinition);\n\n const permission = toolDefinition.permission || {};\n const actorGroups = Array.isArray(actorProfile.allowedToolGroups) ? actorProfile.allowedToolGroups : [];\n const forbiddenGroups = new Set(actorProfile.forbiddenToolGroups || []);\n const toolGroups = Array.isArray(permission.toolGroups) ? permission.toolGroups : [];\n if (!hasToolGroupIntersection(actorGroups, toolGroups)) return false;\n if (toolGroups.some((group) => forbiddenGroups.has(group))) return false;\n\n const actorTypes = Array.isArray(permission.actorTypes) ? permission.actorTypes : [];\n if (actorTypes.length && !actorTypes.includes(actorProfile.actorType)) return false;\n\n const authorityTiers = Array.isArray(permission.authorityTiers) ? permission.authorityTiers : [];\n if (authorityTiers.length && !authorityTiers.includes(actorProfile.authorityTier)) return false;\n\n if (permission.requiresJurisdiction) {\n const refs = Array.isArray(actorProfile.jurisdictionRefs) ? actorProfile.jurisdictionRefs : [];\n const requestedRef = cleanText(options.jurisdictionRef, \"\", 96);\n if (!refs.length) return false;\n if (requestedRef && !refs.includes(requestedRef)) return false;\n }\n\n return true;\n}\n\nfunction filterActorTools(actorProfile, toolRegistry, options = {}) {\n return toolListFromRegistry(toolRegistry).filter((toolDefinition) => {\n try {\n return isToolAllowedForActor(actorProfile, toolDefinition, options);\n } catch (_error) {\n return false;\n }\n });\n}\n\nfunction buildAiActorProfileView(profile = {}) {\n if (!profile || typeof profile !== \"object\") return null;\n return {\n schemaVersion: profile.schemaVersion || AI_ACTOR_PROFILE_SCHEMA_VERSION,\n actorId: cleanId(profile.actorId, \"\"),\n actorType: cleanText(profile.actorType, \"\", 40),\n label: cleanText(profile.label, \"\", 80),\n displayName: cleanText(profile.displayName, \"\", 80),\n authorityTier: cleanText(profile.authorityTier, \"\", 4),\n authorityLabel: cleanText(profile.authorityLabel, \"\", 40),\n role: cleanText(profile.role, \"\", 80),\n officeId: cleanId(profile.officeId, \"\"),\n bureauId: cleanId(profile.bureauId, \"\"),\n jurisdictionRefs: unique(profile.jurisdictionRefs, 12),\n allowedToolGroups: normalizeToolGroups(profile.allowedToolGroups, []),\n visibilityPreset: cleanText(profile.visibilityProfile?.preset, \"\", 40),\n visibilityDomains: unique(profile.visibilityProfile?.readDomains, 12),\n visibilityBudget: {\n maxRows: profile.visibilityProfile?.maxRows || 0,\n maxChars: profile.visibilityProfile?.maxChars || 0\n },\n budgetPreset: cleanText(profile.budgetPolicy?.preset, \"\", 40),\n budgetPolicy: {\n maxCallsPerTenDay: profile.budgetPolicy?.maxCallsPerTenDay || 0,\n maxToolCallsPerScene: profile.budgetPolicy?.maxToolCallsPerScene || 0,\n allowStreaming: profile.budgetPolicy?.allowStreaming === true,\n fallbackMode: cleanText(profile.budgetPolicy?.fallbackMode, \"\", 40)\n },\n modelPolicy: cleanText(profile.modelPolicy, \"\", 64),\n currentGoals: unique(profile.currentGoals, 8),\n knownRefs: unique(profile.knownRefs, 14),\n boundaryStatement: cleanText(profile.boundaryStatement, \"\", 180)\n };\n}\n\nfunction summarizeAiActorProfileForPrompt(profile = {}) {\n const view = buildAiActorProfileView(profile);\n if (!view) return null;\n return {\n actorId: view.actorId,\n actorType: view.actorType,\n authorityTier: view.authorityTier,\n role: view.role,\n allowedToolGroups: view.allowedToolGroups,\n visibilityDomains: view.visibilityDomains,\n currentGoals: view.currentGoals.slice(0, 5),\n boundaryStatement: view.boundaryStatement\n };\n}\n\nmodule.exports = {\n buildAiActorProfileView,\n buildNpcAiActorProfile,\n buildOfficeAiActorProfile,\n buildPlayerAiActorProfile,\n buildSystemEngineActorProfile,\n filterActorTools,\n isToolAllowedForActor,\n summarizeAiActorProfileForPrompt\n};\n"}}}} |