diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index e4d7170..7dcc4c7 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -32,4 +32,13 @@ jobs:
run: node scripts/validate-skins.mjs
- name: Release check (should fail-closed on placeholders)
- run: node scripts/check-release-readiness.mjs; code=$?; if [ $code -eq 1 ]; then echo "release check correctly failed-closed on placeholders"; exit 0; fi; exit $code
+ run: |
+ set +e
+ node scripts/check-release-readiness.mjs
+ code=$?
+ set -e
+ if [ "$code" -eq 1 ]; then
+ echo "release check correctly failed-closed on placeholders"
+ exit 0
+ fi
+ exit "$code"
diff --git a/.gitignore b/.gitignore
index f4a7e06..4dee1e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,12 +4,13 @@ dist/
*.log
.env
.tmp/
+.tmp-chrome*/
tmp/
test-output/
coverage/
MINIGAME_V4_Starter_Pack.zip
android-minigame/
-android-webview/app/src/main/assets/assets/
+android-webview/app/src/main/assets/
.tools/
.gradle/
android-webview/.gradle/
diff --git a/android-webview/app/src/main/assets/game.js b/android-webview/app/src/main/assets/game.js
deleted file mode 100644
index f0b1abf..0000000
--- a/android-webview/app/src/main/assets/game.js
+++ /dev/null
@@ -1,2906 +0,0 @@
-/**
- * MINIGAME - Android WebView 小游戏构建
- * 构建标记: deterministic
- * 请勿手动修改此文件
- */
-(function() {
-'use strict';
-
-// --- src/gameConfig.js ---
-/**
- * gameConfig.js — MINIGAME 平衡参数配置(单一配置源)
- *
- * v2.0 平衡调优:
- * - 更平滑的难度曲线
- * - 操作策略深度加深
- * - 新手友好但高手有挑战
- *
- * 用法:
- * import CONFIG from './gameConfig.js';
- * CONFIG.tick.powerDrainMoving // 0.5
- */
-
-const CONFIG = {
- /* ── 初始状态 ── */
- initial: {
- floor: 1,
- door: 'closed',
- moving: false,
- direction: 'idle',
- power: 100,
- stability: 100,
- anomalyLevel: 0,
- passengers: 0,
- gameOver: false,
- duration: 60, // 值守倒计时(秒)
- },
-
- /* ── 每 Tick(1 秒)消耗 ── */
- tick: {
- powerDrainMoving: 0.5, // 移动中每秒电源消耗(↓0.7)
- powerDrainIdle: 0.15, // 待机每秒电源消耗(↓0.18)
- stabilityDrainMoving: 0.2, // 移动中每秒稳定度消耗(↓0.25)
- },
-
- /* ── 操作消耗/效果 ── */
- actions: {
- moveUp: {
- powerCost: 5, // ↓6
- stabilityCost: 1.5, // ↓2
- },
- moveDown: {
- powerCost: 5, // ↓6
- stabilityCost: 1.5, // ↓2
- },
- emergencyStop: {
- stabilityCost: 4, // ↓6
- stabilityCostOnFailure: 12, // ↓16 急停失效时的额外惩罚
- },
- restartSystem: {
- anomalyLevelReduce: 2,
- stabilityRestore: 20, // ↑15 更值得用
- powerCost: 8, // ↓10
- },
- },
-
- /* ── 失败条件 ── */
- failure: {
- powerMin: 0,
- stabilityMin: 0,
- anomalyLevelMax: 6,
- passengersMin: 0,
- },
-
- /* ── 世界边界 ── */
- bounds: {
- maxFloor: 30,
- },
-
- /* ── 异常系统 ── */
- anomaly: {
- firstTriggerAt: 8, // 首局 10 秒内抛出异常,尽快进入找异常循环
- firstMaxSeverity: 2, // 首个异常只做教学压力,不直接抽高危事件
- cooldownMin: 8, // 双按钮循环:每局约 6–7 个异常
- cooldownMax: 11, // 异常间插入正常班次,保持高密度但可观察
- pressureDivisor: 2, // pickNextAnomaly 压力算法分母
-
- // 难度递增:每 elapsedSeconds 的异常效果乘数
- // formula: Math.pow(difficultyScale, elapsedSeconds / difficultyInterval)
- difficultyScale: 1.06, // 每 10 秒异常效果变为 1.06x
- difficultyInterval: 10, // 间隔秒数
- },
-
- /* ── 广告复活 ── */
- adRevive: {
- rollbackWindow: 30, // 回滚到多少秒前的快照
- snapshotInterval: 10, // 每 N 秒存一次快照
- maxSnapshots: 12, // 最多保留快照数
- },
-
- /* ── 日志 ── */
- logs: {
- maxLines: 80,
- displayLines: 18,
- },
-
- /* ── 隐藏日志(广告解锁) ── */
- hiddenLogs: {
- maxUnlockedPerRun: 5,
- },
-
- /* ── 假结局 ── */
- fakeEnding: {
- consecutiveFailuresThreshold: 5,
- cooldownFailures: 3,
- },
-
- /* ── 发布模式 ──
- - false: 开发模式,广告失败也给奖励,方便本地测试
- - true: 发布模式,广告失败提示重试,不无条件发奖励
- */
- releaseMode: false,
-
- /* ── 模拟广告 ── */
- adContent: {
- adVideoDuration: 2000,
- },
-
- /* ── 广告位 ── */
- adUnits: {
- revive: 'adunit-xxxxx_revive',
- decode: 'adunit-xxxxx_decode',
- truth: 'adunit-xxxxx_truth',
- },
-};
-
-CONFIG;
-
-
-// --- src/skins/elevator/skin.json ---
-var __SKIN_DATA__ = {"meta":{"id":"elevator","name":"异常电梯控制台","subtitle":"MINIGAME · ANOMALY SYSTEM SIM"},"monitor":{"initial":"监控画面稳定:1 层轿厢为空。","actions":{"openDoor":"监控:{floor} 层电梯门已打开。门外走廊光线异常。","closeDoor":"监控:轿厢门闭合。画面存在轻微拖影。","moveUp":"监控:电梯上行至 {floor} 层。乘客未看向摄像头。","moveDown":"监控:电梯下行至 {floor} 层。楼层指示灯短暂闪烁。","emergencyStop":"监控:电梯急停。轿厢灯光闪烁 3 次。","restartSystem":"监控:系统重启后恢复画面。部分录像帧丢失。"}},"actionLabels":{"openDoor":"开门","closeDoor":"关门","moveUp":"上行","moveDown":"下行","emergencyStop":"急停","restartSystem":"系统重启","inspectLog":"查看日志","unlockHiddenLog":"解码加密记录"},"doorLabels":{"open":"开启","closed":"关闭"},"directionLabels":{"up":"上行","down":"下行","idle":"待机"},"statusLabels":{"panelTitle":"电梯状态","floor":"楼层","door":"门状态","direction":"方向","passengers":"乘客","power":"电源","stability":"稳定度","anomalyLevel":"异常等级","reviveCount":"广告复活","adHintsCount":"加密解码","hiddenLogsCount":"待解码"},"canvasLabels":{"countdown":"值守倒计时","monitorPanel":"监控画面","actionPanel":"操作面板","logPanel":"系统日志","forceAnomaly":"触发异常测试","failureTitle":"系统崩溃","failureEyebrow":"SYSTEM FAILURE","monitorSignalStable":"SYSTEM: STABLE","monitorSignalUnstable":"SYSTEM: UNSTABLE","monitorSignalCorrupted":"SYSTEM: CORRUPTED","monitorThreat":"THREAT: {level}","failureMetricStability":"稳定度","failureMetricAnomaly":"异常","failureMetricRemaining":"剩余"},"actionFailMessages":{"openDoor_moving":"电梯移动中,禁止开门。","moveUp_doorNotClosed":"门未关闭,禁止移动。","moveDown_doorNotClosed":"门未关闭,禁止移动。","unknownAction":"未知操作:{actionId}","gameOver":"系统已崩溃,必须复活或重新开始。","systemBusy":"当前动作尚未完成,请等待电梯状态稳定。"},"actionFeedback":{"openDoor":"电梯门已打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行。","moveDown":"电梯开始下行。","emergencyStop":"急停已执行。","emergencyStop_fail":"急停按钮失效。","restartSystem":"系统重启完成。","inspectLog":"已查看系统日志。","unlockHiddenLog_noLocked":"没有待解码的加密记录。","unlockHiddenLog_limit":"本局已解码 {count} 条记录,达到上限。"},"actionLogMessages":{"openDoor":"电梯门已在 {floor} 层打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行,当前楼层 {floor}。","moveDown":"电梯开始下行,当前楼层 {floor}。","emergencyStop":"执行急停:移动已停止,稳定度下降。","emergencyStop_fail":"急停按钮无响应。异常等级上升。","restartSystem":"系统重启完成:异常等级下降,但消耗 {cost} 点电源。","inspectLog":"操作员查看系统日志:最近 30 秒存在未授权楼层请求。","inspectLog_hiddenRecords":"发现 {count} 条待解码加密记录。可观看模拟广告解锁完整内容。","unlockHiddenLog_ok":"模拟广告播放完成。加密记录已解码。"},"anomalies":[{"id":"phantom_floor","title":"不存在的楼层","severity":2,"monitor":"监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。","adHint":"楼层显示异常时不要开门,先执行系统重启。","effects":{"floor":"+2","anomalyLevel":2,"stability":-10}},{"id":"camera_delay","title":"监控延迟","severity":1,"monitor":"监控:画面延迟 3 秒。乘客动作与控制台记录不同步。","adHint":"监控延迟时优先查看日志,不要连续移动。","effects":{"anomalyLevel":1,"stability":-6}},{"id":"zero_passenger_shadow","title":"门外有人但乘客数为 0","severity":2,"monitor":"监控:门外站着一个人,但乘客计数器显示 0。","adHint":"乘客数异常时保持关门,先急停再查日志。","effects":{"passengers":0,"anomalyLevel":2,"stability":-12}},{"id":"log_echo","title":"系统日志重复字符","severity":1,"monitor":"监控:系统日志开始重复输出“不要开门”。","adHint":"日志重复通常是轻度异常,系统重启可降低异常等级。","effects":{"anomalyLevel":1,"stability":-5}},{"id":"auto_button","title":"按钮自动亮起","severity":2,"monitor":"监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。","adHint":"按钮自动亮起时不要跟随请求移动,先关门并急停。","effects":{"anomalyLevel":2,"power":-8}},{"id":"stop_failure","title":"急停按钮失效","severity":3,"monitor":"监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。","adHint":"急停失效时不要反复点击,优先系统重启。","effects":{"anomalyLevel":3,"stability":-15}},{"id":"negative_floor","title":"楼层显示为负数","severity":2,"monitor":"监控:楼层显示 -1。摄像头画面出现地下走廊。","adHint":"负数楼层不是正常地下层,立即重启系统。","effects":{"floor":-1,"anomalyLevel":2,"stability":-10}},{"id":"power_drain","title":"电源异常下降","severity":2,"monitor":"监控:备用电源自动接管,但电量仍在下降。","adHint":"电源异常下降时减少移动,优先关门与重启。","effects":{"anomalyLevel":2,"power":-22}},{"id":"door_refuse","title":"电梯门拒绝关闭","severity":2,"monitor":"监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。","adHint":"门拒绝关闭时不要连续按关门,先急停再重启系统。","effects":{"door":"open","anomalyLevel":2,"stability":-10}},{"id":"weight_mismatch","title":"载重数据异常","severity":1,"monitor":"监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。","adHint":"载重异常时优先查日志,乘客数可能被重置。","effects":{"passengers":0,"anomalyLevel":1,"stability":-7}},{"id":"floor_jump","title":"楼层编号跳跃","severity":2,"monitor":"监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。","adHint":"楼层跳跃时减少移动操作,用系统重启恢复楼层显示。","effects":{"floor":"+4","anomalyLevel":2,"stability":-12,"power":-10}},{"id":"emergency_lights","title":"应急灯异常启动","severity":3,"monitor":"监控:轿厢应急灯突然亮起。备用电源消耗加速。","adHint":"应急灯启动时尽量避免移动,立即重启系统可关闭应急灯。","effects":{"anomalyLevel":3,"stability":-14,"power":-20}}],"hiddenLogs":{"phantom_floor":{"title":"未归档楼层施工记录","content":"施工记录(编号模糊):存在未归档的夹层结构,位于正常楼层之间。\\n档案中未找到该夹层的施工许可或验收记录。\\n控制面板能收到来自该夹层的按钮信号,尽管物理按钮不存在于任何楼层。\\n技术人员备注:该信号可能与 3 年前失踪的 3 名工人有关。"},"camera_delay":{"title":"监控系统校准记录","content":"校准日志 #4417:摄像头#03 与#07 存在 3 秒信号延迟。\n技术人员备注:延迟与第 13 层信号干扰有关,建议不要在 13 层停靠。"},"zero_passenger_shadow":{"title":"乘客记录异常说明","content":"传感器技术手册(节选):\n红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。\n维修记录:传感器无故障。热源信号经比对——与员工体温档案不匹配。"},"log_echo":{"title":"日志系统诊断报告","content":"诊断报告 #FD-22-019:\n系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间。\n建议:检查前一值班员的退出状态。"},"auto_button":{"title":"控制系统审计追踪","content":"审计追踪 #AUD-882:\n自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。\n该服务器的最后一条记录:「控制权移交程序未完成」。"},"stop_failure":{"title":"急停系统维护日志","content":"维护日志 #M-341:\n急停回路#2 在定期检查中被标记为「状态:不可用」。\n签署人签名无法识别。签署时间:3 年前。没有后续维修记录。"},"negative_floor":{"title":"地下层勘测报告","content":"建筑勘测报告(内部):\n地下实际存在 4 层结构,但公开图纸仅标注 B1-B2。\nB3-B4 的电梯按钮在出厂时已被移除,但线路仍然通电。"},"power_drain":{"title":"备用电源异常报告","content":"异常报告 #P-877:\n备用电源在无负载状态下持续放电。经查,有一条非授权线路从备用电源柜分接至未知设备。\n线路标签:「不要切断」。"},"door_refuse":{"title":"门控系统事故报告","content":"事故报告 #D-1290:\n门控模块在连续 3 次异常重启后进入保护模式。\n模块日志输出最后一条:「识别到外部干扰信号。拒绝执行 — 保护乘员安全」。"},"weight_mismatch":{"title":"传感器校验记录","content":"校验记录 #W-554:\n载重传感器与红外传感器读数不一致。红外传感器在轿厢空载时检测到热源。\n技术人员备注:请确认值班员在操作前已清空轿厢。"},"floor_jump":{"title":"楼层定位日志","content":"定位日志 #F-213:\nGPS 楼层定位模块在校准前后记录的楼层编号不一致。\n系统自动修正失败。可能原因:参考信号源来自非标设备。"},"emergency_lights":{"title":"应急照明测试报告","content":"测试报告 #E-777:\n应急照明系统在无触发信号的情况下自行启动。\n供电线路检测到寄生回路。回路终端设备编号无法匹配任何已知设备清单。"}},"failure":{"summaries":{"power":"电源耗尽","stability":"稳定度归零","anomalyLevel":"异常等级失控","passengers":"乘客记录出现负数","default":"系统拒绝继续响应"},"defaultHint":"先关门,再重启系统,避免连续移动。","firstRunAdvice":"下次先核对画面、楼层、人数和门状态;一致放行,矛盾封锁。","adHintPrefix":"广告提示:{hint}","adReviveRollback":"广告复活完成:回滚 {seconds} 秒,恢复至可控状态。","adReviveMonitor":"广告复活完成:回滚到 {seconds} 秒前的系统状态。","snapshotFallback":"可观看广告复活,回滚到 {seconds} 秒前的系统状态。","noSnapshotFallback":"可观看广告复活,回滚到初始系统状态。"},"fakeEnding":{"eyebrow":"⚠ SYSTEM ANOMALY DETECTED","title":"操作员关联异常","text":"系统检测到操作员第 {count} 次系统崩溃。\n根据《异常控制员守则》第 7 条,您已被标记为“异常关联人员”。\n前 {threshold} 次记录已被永久删除。\n建议您立即离开控制台并联系安保部门。","truthPlaceholder":"[???] 观看广告揭示真相。","truthContent":"这不是第一次,也不会是最后一次。\n这座建筑的异常系统从未被修复。\n每一任值班员最后都变成了「异常事件」本身。\n系统日志中关于「乘客」的记载——都是前任值班员的热源信号。\n你现在坐的位置,就是上一任值班员被发现的地方。"},"ui":{"viewAd":"观看广告复活","unlockAd":"解码加密记录","restart":"重新开始","revealTruth":"观看广告揭示真相","triggerTest":"触发异常测试","decodePrefix":"[解码记录]","initialLog":"异常电梯控制台已接管。等待操作员指令。","initialFeedback":"等待下一班电梯","tutorialNormal":"信息一致,点击放行","tutorialAnomaly":"发现矛盾,点击封锁","coreRule":"核对画面和数据:一致放行,矛盾封锁","standby":"等待下一班","wrongTutorial":"再看一眼:核对楼层、人数和门状态","wrongTreatment":"处置错误,异常仍在持续。","inspectionReady":"请核对当前画面和三项数据","treatmentTutorial":"最后一步:按亮起的处置键解除异常","wrongTreatmentTutorial":"这项处置不对应当前线索,再看一次","autoResolutionCorrect":"封锁成功,系统已自动处置","autoResolutionWrong":"判断错误,系统已紧急隔离","autoResolutionTimeout":"判断超时,系统已自动隔离","anomalyEventLog":"异常事件:{title}。{hint}","startTitle":"等待接管异常电梯","startCopy":"核对楼层、人数和门状态:对得上就放行,对不上就封锁。前两班会在实际画面中教会你。","startChecklist":"三项一致:放行\n任意一项矛盾:封锁\n前两班点错不会扣分","startFailureRulesTitle":"失败条件","startFailureRules":"电源归零\n稳定度归零\n异常等级失控","startButton":"开始接管","sidebarEntry":"侧边栏入口","pausedTitle":"值守已暂停","pausedCopy":"返回前台后继续,不计算后台时间","audioOn":"声音开","audioOff":"已静音","adUnavailable":"广告暂不可用,请稍后重试","reportNormal":"放行","reportAnomaly":"封锁","inspectionLabel":"请在 {seconds}s 内判断","baselineInspectionTitle":"核对画面与数据","anomalyInspectionTitle":"核对画面与数据","anomalyResolved":"处置完成:{action} 已解除当前异常。","anomalyResolvedMonitor":"监控恢复稳定,等待下一轮巡检。","inspectionPrompt":"巡检判定:{title}({seconds}秒内响应)","inspectionCorrectNormal":"判定正确:当前画面正常。","inspectionCorrectAnomaly":"判定正确:异常已上报,系统压力下降。","inspectionWrong":"判定错误:稳定度下降,异常压力上升。","inspectionTimeout":"判定超时:未完成本次巡检。","successfulShift":"本轮结束,连续失败计数已重置。","shiftComplete":"值守完成","hiddenLogCaptured":"加密记录已捕获:{title}。使用「查看日志」功能解码。","unlockResult":"已解码:{title}","decodeMonitor":"解码完成:{title}。完整内容已写入系统日志。"}};
-
-// --- src/skinManager.js ---
-/**
- * skinManager.js — 换皮系统核心
- *
- * 负责加载皮肤 JSON 并提供模板字符串替换 (t函数)。
- * 所有游戏内容文本集中管理,实现换皮 = 换 JSON。
- *
- * 用法:
- * import { t, anom, loadSkin } from './skinManager.js';
- * t('meta.name'); // "异常电梯控制台"
- * t('actionLabels.openDoor'); // "开门"
- * t('monitor.actions.moveUp', { floor: 5 }); // 带模板参数
- * anom('phantom_floor').title; // 获取异常事件数据
- */
-
-
-let currentSkin = __SKIN_DATA__;
-
-/**
- * 加载指定皮肤数据
- * @param {object} skinData — 皮肤 JSON 对象
- */
-function loadSkin(skinData) {
- currentSkin = skinData;
-}
-
-/** 获取当前皮肤对象 */
-function getSkin() {
- return currentSkin;
-}
-
-/**
- * 根据点分 key 获取皮肤文本,支持 {param} 模板替换
- * @param {string} key — 如 'meta.name'、'actionLabels.openDoor'
- * @param {object} params — 可选模板参数
- * @returns {string}
- */
-function t(key, params = {}) {
- const value = key.split('.').reduce((o, k) => (o != null ? o[k] : undefined), currentSkin);
- if (value === undefined || value === null) {
- console.warn(`[skinManager] missing key: ${key}`);
- return `{${key}}`;
- }
- if (typeof value === 'string') {
- return value.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
- }
- return value;
-}
-
-/**
- * 获取所有异常事件定义(来自皮肤)
- * @returns {Array<{id, title, severity, monitor, adHint, effects}>}
- */
-function getAnomalies() {
- return currentSkin.anomalies || [];
-}
-
-/**
- * 按 ID 获取单个异常定义
- */
-function getAnomaly(id) {
- return (currentSkin.anomalies || []).find(a => a.id === id) || null;
-}
-
-/**
- * 获取异常关联的隐藏日志
- */
-function getHiddenLog(anomalyId) {
- return currentSkin.hiddenLogs?.[anomalyId] || null;
-}
-
-/**
- * 创建异常事件的 effects 应用到 state 上
- * @param {object} state — 当前游戏状态
- * @param {object} effects — 来自皮肤的 effects 对象
- * @returns {object} 新的 state
- */
-function applyEffects(state, effects) {
- const next = { ...state };
- for (const [field, value] of Object.entries(effects || {})) {
- if (typeof value === 'number') {
- next[field] = (next[field] ?? 0) + value;
- } else if (typeof value === 'string' && value.startsWith('+')) {
- next[field] = (next[field] ?? 0) + parseInt(value, 10);
- } else {
- // 直接赋值(如 door: 'open', floor: 13)
- next[field] = value;
- }
- }
- return next;
-}
-
-/**
- * 获取操作反馈文本
- */
-function actionText(actionId, key, params = {}) {
- return t(`action${key}.${actionId}`, params);
-}
-
-/**
- * 获取操作标签文本
- */
-function actionLabel(actionId, count) {
- const label = t(`actionLabels.${actionId}`);
- if (count !== undefined) return `${label} (${count})`;
- return label;
-}
-
-
-// --- src/rollback.js ---
-
-function findRollbackSnapshot(snapshots, elapsed) {
- if (!snapshots || snapshots.length === 0) return null;
- const targetElapsed = Math.max(0, elapsed - CONFIG.adRevive.rollbackWindow);
- let best = snapshots[0];
- let bestDist = Math.abs(best.at - targetElapsed);
- for (const snap of snapshots) {
- const dist = Math.abs(snap.at - targetElapsed);
- if (dist < bestDist) {
- bestDist = dist;
- best = snap;
- }
- }
- return best;
-}
-
-
-// --- src/feedback.js ---
-
-
-function classifyFeedbackPriority(type) {
- if (type === 'danger') return 'high';
- if (type === 'ad') return 'special';
- if (type === 'success') return 'success';
- if (type === 'warn') return 'medium';
- return 'normal';
-}
-
-function createFeedbackLine(type, message, time = 0) {
- const safeTime = Math.max(0, Math.floor(time));
- const minutes = String(Math.floor(safeTime / 60)).padStart(2, '0');
- const seconds = String(safeTime % 60).padStart(2, '0');
- return {
- type,
- priority: classifyFeedbackPriority(type),
- time: safeTime,
- text: `[${minutes}:${seconds}] ${message}`,
- };
-}
-
-function summarizeFailure(state) {
- const reasons = [];
- const s = state;
- if (s.power <= 0) reasons.push(t('failure.summaries.power'));
- if (s.stability <= 0) reasons.push(t('failure.summaries.stability'));
- if (s.anomalyLevel >= 6) reasons.push(t('failure.summaries.anomalyLevel'));
- if (s.passengers < 0) reasons.push(t('failure.summaries.passengers'));
- if (reasons.length === 0) reasons.push(t('failure.summaries.default'));
-
- const snapshots = s.snapshots || [];
- let rollbackSec = 0;
- if (snapshots.length > 0) {
- const best = findRollbackSnapshot(snapshots, s.elapsed);
- rollbackSec = s.elapsed - best.at;
- }
-
- const firstRunAdvice = s.adRevivesUsed === 0 && (s.anomaliesTriggeredTotal ?? 0) <= 1
- ? ` ${t('failure.firstRunAdvice')}`
- : '';
-
- if (snapshots.length > 0) {
- return `${reasons.join('、')}。${t('failure.snapshotFallback', { seconds: rollbackSec })}${firstRunAdvice}`;
- }
- return `${reasons.join('、')}。${t('failure.noSnapshotFallback')}${firstRunAdvice}`;
-}
-
-function getToneForState(state) {
- if (state.result === 'success') return 'normal';
- if (state.gameOver) return 'danger';
- if (state.anomalyLevel >= 4 || state.stability < 35) return 'critical';
- if (state.anomalyLevel >= 2 || state.power < 45) return 'warn';
- return 'normal';
-}
-
-
-// --- src/anomalyContent.js ---
-/**
- * anomalyContent.js — 异常内容模式定义与结构化数据
- *
- * 为每个异常定义正式的 screenData / panelData / primaryConflict 三元组,
- * 确保所有判断基于具体可观察线索,而非标题/颜色/答案高亮。
- *
- * 设计原则(#6、#7):
- * - 静音和色盲状态下仍可判断(线索为文字/数据矛盾,不依赖声音或颜色)
- * - 每项异常必须具备可观察、可解释、可复盘的具体线索
- * - screenData 和 panelData 同时可供生成 CCTV 素材时的来源字段
- */
-
-// ─── 类型文档 ──────────────────────────────────────────────
-/**
- * @typedef {Object} AnomalyContent
- * @property {string} id — 异常 ID,与 skin.json 与 events.js 一致
- * @property {string} title — 短标题
- * @property {number} severity — 1=轻度 2=中度 3=重度
- * @property {1|2|3} difficulty — 玩家判断难度 1=明显矛盾 2=需核对 3=需复盘
- * @property {'release'|'lockdown'} correctDecision — 正确玩家操作
- *
- * @property {Object} screenData — CCTV 画面呈现的数据
- * @property {number} screenData.floor — 画面中显示的楼层号
- * @property {number} screenData.passengers — 画面中可见人数
- * @property {'open'|'closed'} screenData.door — 画面中门状态
- * @property {'idle'|'up'|'down'} screenData.direction — 画面中电梯方向
- *
- * @property {Object} panelData — 控制台面板显示的数据
- * @property {number} panelData.floor
- * @property {number} panelData.passengers
- * @property {'open'|'closed'} panelData.door
- * @property {'idle'|'up'|'down'} panelData.direction
- *
- * @property {string} primaryConflict — 关键矛盾的中文描述(局后复盘用)
- * @property {string} explanation — 异常原因的中文说明(档案库用)
- * @property {string} visualState — 对应的 CCTV 状态 ID
- * @property {string} audioCue — 异常触发时的音频 cue 名称
- * @property {string} resolutionAction — 系统自动处置动作 ID
- * @property {string} monitorTemplate — 皮肤 monitor 文案 key 或模板
- * @property {number} stabilityPenalty — 稳定度基准惩罚(已乘难度系数前)
- * @property {number} powerPenalty — 电源基准惩罚
- *
- * @property {Object} [normalVariant] — 对应的正常变体(用于随机正常班次)
- * @property {number} normalVariant.floor
- * @property {number} normalVariant.passengers
- * @property {'open'|'closed'} normalVariant.door
- * @property {'idle'|'up'|'down'} normalVariant.direction
- */
-
-// ─── 类别说明 ──────────────────────────────────────────────
-// 单项数据矛盾:screenData 与 panelData 仅一个字段不同
-// 延迟/状态冲突:screenData 显示的是上一帧或错误状态
-// 视觉复合异常:多个字段同时矛盾,或存在逻辑矛盾
-
-/** @type {AnomalyContent[]} */
-const ANOMALY_CONTENTS = [
- // ══════════════════════════════════════════════════════════
- // 4 个单项数据矛盾
- // ══════════════════════════════════════════════════════════
- {
- id: 'phantom_floor',
- title: '不存在的楼层',
- severity: 2,
- difficulty: 1,
- correctDecision: 'lockdown',
-
- screenData: { floor: 4, passengers: 1, door: 'closed', direction: 'idle' },
- panelData: { floor: 2, passengers: 1, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面楼层比控制台高 2 层(画面层 4,控制台层 2)',
- explanation: '电梯在建筑图纸不存在的夹层停靠。该夹层位于正常楼层之间,施工记录已丢失,但控制面板仍能收到来自该层的信号。',
- visualState: '16_wrong_floor',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。',
- stabilityPenalty: -10,
- powerPenalty: 0,
-
- normalVariant: { floor: 2, passengers: 1, door: 'closed', direction: 'idle' },
- },
-
- {
- id: 'negative_floor',
- title: '楼层显示为负数',
- severity: 2,
- difficulty: 1,
- correctDecision: 'lockdown',
-
- screenData: { floor: -1, passengers: 1, door: 'closed', direction: 'down' },
- panelData: { floor: 5, passengers: 1, door: 'closed', direction: 'down' },
-
- primaryConflict: '画面显示 -1 层,控制台显示 5 层',
- explanation: '电梯进入建筑图纸未标注的地下结构。实际存在 B3-B4 层,但按钮在出厂时已被移除,线路仍然通电。',
- visualState: '16_wrong_floor',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:楼层显示 -1。摄像头画面出现地下走廊。',
- stabilityPenalty: -10,
- powerPenalty: 0,
-
- normalVariant: { floor: 5, passengers: 1, door: 'closed', direction: 'down' },
- },
-
- {
- id: 'weight_mismatch',
- title: '载重数据异常',
- severity: 1,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 1, passengers: 1, door: 'closed', direction: 'idle' },
- panelData: { floor: 1, passengers: 0, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面有 1 人,控制台乘客计数为 0',
- explanation: '红外传感器在轿厢内检测到热源信号,但载重传感器读数为零。热源信号经比对与员工体温档案不匹配。',
- visualState: '14_shadow_inside',
- audioCue: 'anomaly',
- resolutionAction: 'inspectLog',
- monitorTemplate: '监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。',
- stabilityPenalty: -7,
- powerPenalty: 0,
-
- normalVariant: { floor: 1, passengers: 1, door: 'closed', direction: 'idle' },
- },
-
- {
- id: 'zero_passenger_shadow',
- title: '门外有人但乘客数为 0',
- severity: 2,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 3, passengers: 1, door: 'open', direction: 'idle' },
- panelData: { floor: 3, passengers: 0, door: 'open', direction: 'idle' },
-
- primaryConflict: '画面显示 1 人在外等候,控制台乘客计数为 0',
- explanation: '红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。传感器无硬件故障。',
- visualState: '15_anomaly_wandering',
- audioCue: 'anomaly',
- resolutionAction: 'inspectLog',
- monitorTemplate: '监控:门外站着一个人,但乘客计数器显示 0。',
- stabilityPenalty: -12,
- powerPenalty: 0,
-
- normalVariant: { floor: 3, passengers: 1, door: 'open', direction: 'idle' },
- },
-
- // ══════════════════════════════════════════════════════════
- // 4 个延迟/状态冲突
- // ══════════════════════════════════════════════════════════
- {
- id: 'camera_delay',
- title: '监控延迟',
- severity: 1,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 5, passengers: 2, door: 'closed', direction: 'up' },
- panelData: { floor: 7, passengers: 2, door: 'closed', direction: 'up' },
-
- primaryConflict: 'CCTV 楼层停留在 5 层,控制台已到 7 层',
- explanation: '摄像头#03 与#07 存在持续信号延迟。延迟与第 13 层信号干扰有关,不建议在该层停靠。',
- visualState: '11_camera_glitch',
- audioCue: 'anomaly',
- resolutionAction: 'inspectLog',
- monitorTemplate: '监控:画面延迟 3 秒。乘客动作与控制台记录不同步。',
- stabilityPenalty: -6,
- powerPenalty: 0,
-
- normalVariant: { floor: 7, passengers: 2, door: 'closed', direction: 'up' },
- },
-
- {
- id: 'door_refuse',
- title: '电梯门拒绝关闭',
- severity: 2,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 4, passengers: 1, door: 'open', direction: 'idle' },
- panelData: { floor: 4, passengers: 1, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面显示门开着,控制台显示门已关闭',
- explanation: '门控模块在连续 3 次异常重启后进入保护模式。模块检测到外部干扰信号,拒绝执行关门指令以保护乘员安全。',
- visualState: '09_door_jammed',
- audioCue: 'anomaly',
- resolutionAction: 'closeDoor',
- monitorTemplate: '监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。',
- stabilityPenalty: -10,
- powerPenalty: 0,
-
- normalVariant: { floor: 4, passengers: 1, door: 'closed', direction: 'idle' },
- },
-
- {
- id: 'log_echo',
- title: '系统日志重复字符',
- severity: 1,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 2, passengers: 1, door: 'closed', direction: 'idle' },
- panelData: { floor: 2, passengers: 1, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面和控制台数据一致,但日志系统输出异常(画面三要素一致时仍需判断日志线索)',
- explanation: '系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间,表明前一值班员的退出状态异常。',
- visualState: '17_loop_corridor',
- audioCue: 'anomaly',
- resolutionAction: 'inspectLog',
- monitorTemplate: '监控:系统日志开始重复输出"不要开门"。',
- stabilityPenalty: -5,
- powerPenalty: 0,
-
- normalVariant: { floor: 2, passengers: 1, door: 'closed', direction: 'idle' },
- },
-
- {
- id: 'auto_button',
- title: '按钮自动亮起',
- severity: 2,
- difficulty: 3,
- correctDecision: 'lockdown',
-
- screenData: { floor: 6, passengers: 0, door: 'closed', direction: 'idle' },
- panelData: { floor: 6, passengers: 0, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面三要素一致,但控制台有非授权楼层请求(B2 和 9 层按钮自动亮起)',
- explanation: '自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。该服务器的最后一条记录为「控制权移交程序未完成」。',
- visualState: '12_scan_active',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。',
- stabilityPenalty: 0,
- powerPenalty: -8,
-
- normalVariant: { floor: 6, passengers: 0, door: 'closed', direction: 'idle' },
- },
-
- // ══════════════════════════════════════════════════════════
- // 4 个视觉复合异常
- // ══════════════════════════════════════════════════════════
- {
- id: 'stop_failure',
- title: '急停按钮失效',
- severity: 3,
- difficulty: 3,
- correctDecision: 'lockdown',
-
- screenData: { floor: 8, passengers: 2, door: 'closed', direction: 'down' },
- panelData: { floor: 8, passengers: 2, door: 'closed', direction: 'down' },
-
- primaryConflict: '画面三要素一致,但急停指示灯熄灭且控制台拒绝确认安全回路(复合硬件异常)',
- explanation: '急停回路#2 在定期检查中被标记为「不可用」。签署人签名无法识别,签署时间为 3 年前,没有后续维修记录。',
- visualState: '08_emergency_stop',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。',
- stabilityPenalty: -15,
- powerPenalty: 0,
-
- normalVariant: { floor: 8, passengers: 2, door: 'closed', direction: 'down' },
- },
-
- {
- id: 'power_drain',
- title: '电源异常下降',
- severity: 2,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 7, passengers: 1, door: 'closed', direction: 'idle' },
- panelData: { floor: 7, passengers: 1, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面三要素一致,但电源持续下降(非正常消耗速率,需观察电源条趋势)',
- explanation: '备用电源在无负载状态下持续放电。有一条非授权线路从备用电源柜分接至未知设备,线路标签为「不要切断」。',
- visualState: '06_power_low',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:备用电源自动接管,但电量仍在下降。',
- stabilityPenalty: 0,
- powerPenalty: -22,
-
- normalVariant: { floor: 7, passengers: 1, door: 'closed', direction: 'idle' },
- },
-
- {
- id: 'floor_jump',
- title: '楼层编号跳跃',
- severity: 2,
- difficulty: 2,
- correctDecision: 'lockdown',
-
- screenData: { floor: 9, passengers: 1, door: 'closed', direction: 'up' },
- panelData: { floor: 5, passengers: 1, door: 'closed', direction: 'up' },
-
- primaryConflict: 'CCTV 直接显示 9 层,控制台仍在 5 层(非连续移动,帧丢失)',
- explanation: 'GPS 楼层定位模块在校准前后记录的楼层编号不一致。系统自动修正失败,参考信号源来自非标设备。',
- visualState: '16_wrong_floor',
- audioCue: 'anomaly',
- resolutionAction: 'inspectLog',
- monitorTemplate: '监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。',
- stabilityPenalty: -12,
- powerPenalty: -10,
-
- normalVariant: { floor: 5, passengers: 1, door: 'closed', direction: 'up' },
- },
-
- {
- id: 'emergency_lights',
- title: '应急灯异常启动',
- severity: 3,
- difficulty: 3,
- correctDecision: 'lockdown',
-
- screenData: { floor: 10, passengers: 2, door: 'closed', direction: 'idle' },
- panelData: { floor: 10, passengers: 2, door: 'closed', direction: 'idle' },
-
- primaryConflict: '画面三要素一致,但应急灯突然亮起且备用电源加速消耗(需识别异常氛围与电源趋势的复合矛盾)',
- explanation: '应急照明系统在无触发信号的情况下自行启动。供电线路检测到寄生回路,终端设备编号无法匹配任何已知设备清单。',
- visualState: '07_power_outage',
- audioCue: 'anomaly',
- resolutionAction: 'restartSystem',
- monitorTemplate: '监控:轿厢应急灯突然亮起。备用电源消耗加速。',
- stabilityPenalty: -14,
- powerPenalty: -20,
-
- normalVariant: { floor: 10, passengers: 2, door: 'closed', direction: 'idle' },
- },
-];
-
-// ─── 辅助函数 ──────────────────────────────────────────────
-
-/** 按 ID 查找异常内容 */
-function findAnomalyContent(id) {
- return ANOMALY_CONTENTS.find(a => a.id === id) || null;
-}
-
-/** 获取所有异常内容 */
-function getAllAnomalyContents() {
- return ANOMALY_CONTENTS;
-}
-
-/** 判断 screenData 与 panelData 是否一致(正常班次条件) */
-function isDataConsistent(content) {
- return (
- content.screenData.floor === content.panelData.floor &&
- content.screenData.passengers === content.panelData.passengers &&
- content.screenData.door === content.panelData.door &&
- content.screenData.direction === content.panelData.direction
- );
-}
-
-/** 获取 primaryConflict 列表 */
-function getConflictFields(content) {
- const conflicts = [];
- if (content.screenData.floor !== content.panelData.floor) conflicts.push('floor');
- if (content.screenData.passengers !== content.panelData.passengers) conflicts.push('passengers');
- if (content.screenData.door !== content.panelData.door) conflicts.push('door');
- if (content.screenData.direction !== content.panelData.direction) conflicts.push('direction');
- return conflicts;
-}
-
-// ─── 10+ 正常班次变体 ──────────────────────────────────
-//
-// 这些变体用于生成与异常画面相似但数据一致的正常巡检,
-// 避免玩家形成"画面变化 = 异常"的条件反射。
-//
-// 每个变体:screenData === panelData(三要素一致)
-
-/**
- * @typedef {Object} NormalVariant
- * @property {number} floor
- * @property {number} passengers
- * @property {'open'|'closed'} door
- * @property {'idle'|'up'|'down'} direction
- * @property {string} scenario — 场景中文描述
- * @property {string} visualState — CCTV 状态 ID
- */
-
-/** @type {NormalVariant[]} */
-const NORMAL_VARIANTS = [
- // 空轿厢静止
- { floor: 1, passengers: 0, door: 'closed', direction: 'idle', scenario: '首层待机,轿厢空载', visualState: '00_idle_closed' },
-
- // 单乘客正常移动
- { floor: 3, passengers: 1, door: 'closed', direction: 'up', scenario: '单客上行至 3 层', visualState: '04_moving_up' },
- { floor: 6, passengers: 1, door: 'closed', direction: 'down', scenario: '单客下行至 6 层', visualState: '05_moving_down' },
-
- // 多乘客接客
- { floor: 2, passengers: 2, door: 'open', direction: 'idle', scenario: '2 层开门接客,2 人在外等候', visualState: '01_door_open' },
- { floor: 5, passengers: 3, door: 'closed', direction: 'up', scenario: '3 人上行至 5 层', visualState: '04_moving_up' },
-
- // 中等楼层
- { floor: 8, passengers: 0, door: 'closed', direction: 'idle', scenario: '8 层待机,空载', visualState: '00_idle_closed' },
- { floor: 10, passengers: 1, door: 'closed', direction: 'down', scenario: '10 层下行,单客回家', visualState: '05_moving_down' },
- { floor: 4, passengers: 2, door: 'open', direction: 'idle', scenario: '4 层开门,2 人出梯', visualState: '02_door_opening' },
-
- // 接近异常的楼层但数据一致(防止模式识别)
- { floor: 13, passengers: 0, door: 'closed', direction: 'idle', scenario: '13 层待机(已开门),空载——普通停靠并非异常', visualState: '01_door_open' },
- { floor: -1, passengers: 0, door: 'closed', direction: 'idle', scenario: '-1 层(地下停车场标准层)待机', visualState: '00_idle_closed' },
-
- // 方向变化
- { floor: 7, passengers: 1, door: 'closed', direction: 'up', scenario: '单客上行前往 7 层', visualState: '04_moving_up' },
- { floor: 9, passengers: 2, door: 'closed', direction: 'down', scenario: '2 人从 9 层下行', visualState: '05_moving_down' },
-];
-
-/** 获取随机正常变体 */
-function pickNormalVariant(random = Math.random) {
- return NORMAL_VARIANTS[Math.floor(random() * NORMAL_VARIANTS.length)];
-}
-
-// ─── CCTV 状态映射 ──────────────────────────────────
-//
-// 完整 CCTV 资产状态列表(按 ID 排序以匹配 asset manifest)
-// 00_idle_closed 01_door_open 02_door_opening 03_door_closing
-// 04_moving_up 05_moving_down 06_power_low 07_power_outage
-// 08_emergency_stop 09_door_jammed 10_signal_lost 11_camera_glitch
-// 12_scan_active 13_entity_near 14_shadow_inside 15_anomaly_wandering
-// 16_wrong_floor 17_loop_corridor 18_corridor_dark 19_stabilized
-// 20_threat_high 21_containment 22_system_reboot 23_cooldown_safe
-
-/** 按 anomaly ID 获取 CCTV 状态 */
-function getAnomalyCctvState(anomalyId) {
- return ANOMALY_CONTENTS.find(a => a.id === anomalyId)?.visualState || null;
-}
-
-/** 按 CCTV 状态 ID 获取该状态对应的所有异常 ID */
-function getAnomaliesByCctvState(cctvState) {
- return ANOMALY_CONTENTS
- .filter(a => a.visualState === cctvState)
- .map(a => a.id);
-}
-
-/** 获取所有正常 CCTV 状态列表(无异常时可见) */
-function getNormalCctvStates() {
- return [
- '00_idle_closed', '01_door_open', '02_door_opening', '03_door_closing',
- '04_moving_up', '05_moving_down', '19_stabilized', '23_cooldown_safe',
- ];
-}
-
-/** 获取所有异常 CCTV 状态列表 */
-function getAnomalyCctvStates() {
- const states = new Set(ANOMALY_CONTENTS.map(a => a.visualState));
- return [...states];
-}
-
-
-// --- src/visualState.js ---
-/**
- * visualState.js — 驱动 CCTV 视觉状态的核心映射
- *
- * V4 重构原则:
- * - CCTV 状态由 anomalyContent.js 的 visualState 字段驱动
- * - 所有异常必须有对应的 visualState 映射
- * - 正常运行期间 CCTV 反映的是实时移动/门体状态而非残余数值
- * - 电源/异常等级警报仅在真正有风险时覆盖画面
- */
-
-function clampVisualValue(value, min, max) {
- return Math.max(min, Math.min(max, value));
-}
-
-// ─── 异常动作提示(用于 V3 DOM 界面 / 非 base 模式的遗留兼容) ──
-const ACTIVE_ANOMALY_ACTION_HINTS = Object.freeze({
- stop_failure: 'restartSystem',
- door_refuse: 'closeDoor',
- phantom_floor: 'inspectLog',
- camera_delay: 'inspectLog',
- log_echo: 'inspectLog',
- auto_button: 'restartSystem',
- floor_jump: 'inspectLog',
- zero_passenger_shadow: 'inspectLog',
- negative_floor: 'inspectLog',
- weight_mismatch: 'inspectLog',
- power_drain: 'restartSystem',
- light_flicker: 'restartSystem',
- emergency_lights: 'restartSystem',
- passenger_duplicate: 'closeDoor',
- door_gap_whisper: 'closeDoor',
- camera_blackout: 'inspectLog',
-});
-
-function getAnomalyResolutionAction(anomalyId) {
- return ACTIVE_ANOMALY_ACTION_HINTS[anomalyId] || null;
-}
-
-function getHighlightAction(state) {
- if (state.gameOver) return 'restartSystem';
- if (state.activeAnomaly && ACTIVE_ANOMALY_ACTION_HINTS[state.activeAnomaly]) {
- return ACTIVE_ANOMALY_ACTION_HINTS[state.activeAnomaly];
- }
- // 正常运行且无活动异常时不高亮任何动作
- if (isNormalRunning(state)) return null;
- if (state.anomalyLevel >= 4) return 'restartSystem';
- if (state.anomalyLevel >= 2) return 'inspectLog';
- return null;
-}
-
-function getTone(anomalyLevel, gameOver) {
- if (gameOver) return 'danger';
- if (anomalyLevel >= 4) return 'critical';
- if (anomalyLevel >= 1) return 'warn';
- return 'normal';
-}
-
-/**
- * 判断当前是否处于"正常运行"状态——没有活动异常、正在或即将巡检、非结算。
- * 此时 CCTV 应反映面板数据(楼层/门/方向),不因残余数值泄题。
- */
-function isNormalRunning(safeState) {
- return !safeState.gameOver
- && safeState.result !== 'success'
- && !safeState.activeAnomaly
- && !safeState.fakeEndingCooldownRemaining;
-}
-
-function getCctvState(state, anomalyLevel) {
- // ── 终局覆盖 ──
- if (state.result === 'success') return '19_stabilized';
- if (state.gameOver || anomalyLevel >= 5) return '20_threat_high';
-
- // ── 活动异常 CCTV 状态 ──
- if (state.activeAnomaly) {
- const cctvState = getAnomalyCctvState(state.activeAnomaly);
- if (cctvState) return cctvState;
- }
-
- // ── 假结局冷却 ──
- if (state.fakeEndingCooldownRemaining > 0) return '23_cooldown_safe';
-
- // ── 正常运行:优先反映面板数据(方向/门),不展示残余数值 ──
- if (isNormalRunning(state)) {
- if (state.direction === 'up') return '04_moving_up';
- if (state.direction === 'down') return '05_moving_down';
- if (state.door === 'open') return '01_door_open';
- if (state.door === 'opening') return '02_door_opening';
- if (state.door === 'closing') return '03_door_closing';
- // 待机状态:稳定度高时显示 stabilized,否则显示默认 idle
- if (state.stability >= 92 && state.elapsed > 0) return '19_stabilized';
- return '00_idle_closed';
- }
-
- // ── 异常活跃期间视觉警报 ──
- if (state.power <= 5) return '07_power_outage';
- if (state.power <= 22) return '06_power_low';
- if (state.direction === 'up') return '04_moving_up';
- if (state.direction === 'down') return '05_moving_down';
- if (state.door === 'open') return '01_door_open';
- if (state.door === 'opening') return '02_door_opening';
- if (state.door === 'closing') return '03_door_closing';
- if (state.stability >= 92 && state.elapsed > 0) return '19_stabilized';
- if (anomalyLevel >= 3) return '13_entity_near';
- if (anomalyLevel > 0) return '10_signal_lost';
- return '00_idle_closed';
-}
-
-function deriveVisualState(state) {
- const rawAnomaly = Number(state?.anomalyLevel ?? 0);
- const success = state?.result === 'success';
- const gameOver = Boolean(state?.gameOver);
-
- // 在正常运行且无活动异常时,不再因为残余 anomalyLevel 非零而触发警报视觉
- const normalRunning = isNormalRunning(state);
- const anomalyLevel = normalRunning ? 0 : rawAnomaly;
-
- const active = Boolean(state?.gameOver && !success) || (!success && (Boolean(state?.activeAnomaly) || (!normalRunning && anomalyLevel > 0)));
- const pressure = clampVisualValue(anomalyLevel / 6, 0, 1);
- const safeState = state ?? {};
-
- return {
- tone: success ? 'normal' : getTone(anomalyLevel, gameOver),
- glitch: active,
- shake: Boolean(state?.gameOver && !success) || (!success && (!normalRunning && anomalyLevel >= 4)),
- noise: success ? 0.18 : gameOver ? 1 : Number((0.18 + pressure * 0.82).toFixed(2)),
- highlightAction: getHighlightAction(safeState),
- cctvState: getCctvState(safeState, anomalyLevel),
- };
-}
-
-
-// --- src/state.js ---
-
-
-
-
-function createInitialState() {
- const c = CONFIG.initial;
- return {
- floor: c.floor,
- door: c.door,
- moving: c.moving,
- direction: c.direction,
- transition: null,
- power: c.power,
- stability: c.stability,
- anomalyLevel: c.anomalyLevel,
- passengers: c.passengers,
- gameOver: c.gameOver,
- result: 'playing',
- elapsed: 0,
- remaining: c.duration,
- adRevivesUsed: 0,
- hiddenLogsUnlocked: 0,
- lastAdHint: '',
- monitor: t('monitor.initial'),
- activeAnomaly: null,
- snapshots: [],
- hiddenLogs: [],
- adHintsUsed: 0,
- consecutiveFailures: 0,
- fakeEndingCount: 0,
- fakeEndingCooldownRemaining: 0,
- fakeEndingTriggered: false,
- fakeEndingUnlocked: false,
- // 复盘统计(局内累积)
- anomaliesTriggeredTotal: 0,
- maxAnomalySeverity: 0,
- inspection: null,
- decisionsCorrect: 0,
- decisionsWrong: 0,
- score: 0,
- streak: 0,
- bestStreak: 0,
- tutorialStep: 0,
- lastFeedback: t('ui.initialFeedback'),
- logs: [createFeedbackLine('info', t('ui.initialLog'), 0)],
- };
-}
-
-function cloneValue(value) {
- if (value === undefined || value === null) return value;
- return JSON.parse(JSON.stringify(value));
-}
-
-function cloneState(state) {
- return cloneValue(state);
-}
-
-function appendLog(state, type, message) {
- const next = cloneState(state);
- next.logs.push(createFeedbackLine(type, message, next.elapsed ?? 0));
- if (next.logs.length > CONFIG.logs.maxLines) next.logs = next.logs.slice(-CONFIG.logs.maxLines);
- return next;
-}
-
-function clamp(value, min, max) {
- return Math.max(min, Math.min(max, value));
-}
-
-function checkFailure(state) {
- const next = cloneState(state);
- const f = CONFIG.failure;
- if (next.power <= f.powerMin || next.stability <= f.stabilityMin || next.anomalyLevel >= f.anomalyLevelMax || next.passengers < f.passengersMin) {
- next.gameOver = true;
- next.result = 'failure';
- next.moving = false;
- next.direction = 'idle';
- next.transition = null;
- }
- return next;
-}
-
-function saveSnapshot(state) {
- const snapshots = [...(state.snapshots || [])];
- // Build a clean copy of the state without the snapshots array (no nesting)
- const clean = {};
- for (const key of Object.keys(state)) {
- if (key === 'snapshots') continue;
- clean[key] = cloneValue(state[key]);
- }
- snapshots.push({ at: state.elapsed, state: clean });
- const next = cloneState(state);
- next.snapshots = snapshots.slice(-CONFIG.adRevive.maxSnapshots);
- return next;
-}
-
-
-function reviveFromAd(state) {
- const snapshots = state.snapshots || [];
- const best = findRollbackSnapshot(snapshots, state.elapsed);
-
- let next;
- if (best) {
- next = cloneState(best.state);
- next.snapshots = snapshots; // preserve snapshot history
- next.rollbackSeconds = state.elapsed - best.at;
- } else {
- // No snapshot early enough — fall back to initial baseline
- next = createInitialState();
- next.snapshots = snapshots;
- next.rollbackSeconds = state.elapsed;
- next.elapsed = state.elapsed; // keep the clock running
- next.remaining = Math.max(1, state.remaining);
- }
-
- next.gameOver = false;
- next.result = 'playing';
- next.door = 'closed';
- next.moving = false;
- next.direction = 'idle';
- next.transition = null;
- next.activeAnomaly = null;
- next.adRevivesUsed += 1;
- next.monitor = t('failure.adReviveMonitor', { seconds: next.rollbackSeconds });
- next = appendLog(next, 'ad', t('failure.adReviveRollback', { seconds: next.rollbackSeconds }));
- return next;
-}
-
-function tickState(state, seconds = 1) {
- let next = cloneState(state);
- const tk = CONFIG.tick;
- next.elapsed += seconds;
- next.remaining = clamp(next.remaining - seconds, 0, CONFIG.initial.duration);
- if (next.moving) {
- next.power = clamp(next.power - seconds * tk.powerDrainMoving, 0, 100);
- next.stability = clamp(next.stability - seconds * tk.stabilityDrainMoving, 0, 100);
- } else {
- next.power = clamp(next.power - seconds * tk.powerDrainIdle, 0, 100);
- }
- if (next.transition) {
- next.transition.remaining = Math.max(0, Number(next.transition.remaining || 0) - seconds);
- if (next.transition.remaining <= 0) {
- if (next.transition.kind === 'movingUp' || next.transition.kind === 'movingDown') {
- next.moving = false;
- next.direction = 'idle';
- }
- next.transition = null;
- }
- }
- if (next.remaining <= 0) {
- next.gameOver = true;
- next.result = 'success';
- next.activeAnomaly = null;
- next.inspection = null;
- next.transition = null;
- next.moving = false;
- next.direction = 'idle';
- next.lastFeedback = t('ui.successfulShift');
- next = appendLog(next, 'success', next.lastFeedback);
- return next;
- }
-
- return checkFailure(next);
-}
-
-function recordSuccessfulShift(state) {
- let next = cloneState(state);
- next.result = 'success';
- next.consecutiveFailures = 0;
- next.fakeEndingCooldownRemaining = 0;
- next.fakeEndingTriggered = false;
- next.fakeEndingUnlocked = false;
- next.fakeEndingCount = 0;
- next = appendLog(next, 'success', t('ui.shiftComplete'));
- return next;
-}
-
-function recordFailure(state) {
- const fe = CONFIG.fakeEnding;
- const next = cloneState(state);
- next.result = 'failure';
- next.consecutiveFailures += 1;
-
- if (next.fakeEndingCooldownRemaining > 0) {
- next.fakeEndingCooldownRemaining -= 1;
- next.fakeEndingTriggered = false;
- return next;
- }
-
- if (next.consecutiveFailures >= fe.consecutiveFailuresThreshold) {
- next.fakeEndingTriggered = true;
- next.fakeEndingUnlocked = false;
- next.fakeEndingCount = next.consecutiveFailures;
- next.consecutiveFailures = 0;
- next.fakeEndingCooldownRemaining = fe.cooldownFailures;
- }
-
- return next;
-}
-
-
-// --- src/incidentDecision.js ---
-
-
-function openInspection(state, options) {
- const duration = Math.max(3, Math.floor(options.duration ?? 7));
- let next = cloneState(state);
- next.inspection = {
- id: options.id,
- kind: options.kind === 'anomaly' ? 'anomaly' : 'normal',
- title: options.title,
- openedAt: next.elapsed ?? 0,
- expiresAt: (next.elapsed ?? 0) + duration,
- status: 'pending',
- choice: null,
- };
- next.lastFeedback = t('ui.inspectionReady');
- next = appendLog(next, 'info', t('ui.inspectionPrompt', {
- title: options.title,
- seconds: duration,
- }));
- return next;
-}
-
-function submitInspection(state, choice) {
- const inspection = state.inspection;
- if (!inspection || inspection.status !== 'pending') {
- return { state, accepted: false, correct: false };
- }
-
- let next = cloneState(state);
- const normalizedChoice = choice === 'anomaly' ? 'anomaly' : 'normal';
- const correct = normalizedChoice === inspection.kind;
- const tutorialStep = Number(next.tutorialStep || 0);
- const guidedRound = (tutorialStep === 0 && inspection.kind === 'normal')
- || (tutorialStep === 1 && inspection.kind === 'anomaly');
-
- // 首两轮用实际操作教学:点错不扣资源、不结束题目,直接在原画面上纠正。
- if (guidedRound && !correct) {
- next.lastFeedback = t('ui.wrongTutorial');
- next = appendLog(next, 'info', next.lastFeedback);
- return { state: next, accepted: false, correct: false, coached: true };
- }
-
- next.inspection = {
- ...inspection,
- status: 'resolved',
- choice: normalizedChoice,
- correct,
- resolvedAt: next.elapsed ?? 0,
- };
- next.decisionsCorrect = (next.decisionsCorrect ?? 0) + (correct ? 1 : 0);
- next.decisionsWrong = (next.decisionsWrong ?? 0) + (correct ? 0 : 1);
-
- if (correct) {
- const secondsLeft = Math.max(0, Math.ceil((inspection.expiresAt ?? next.elapsed ?? 0) - (next.elapsed ?? 0)));
- const points = 100 + secondsLeft * 10;
- next.score = (next.score ?? 0) + points;
- next.streak = (next.streak ?? 0) + 1;
- next.bestStreak = Math.max(next.bestStreak ?? 0, next.streak);
- if (guidedRound) next.tutorialStep = Math.min(2, tutorialStep + 1);
- next.stability = clamp((next.stability ?? 0) + 4, 0, 100);
- if (inspection.kind === 'anomaly') {
- next.anomalyLevel = clamp((next.anomalyLevel ?? 0) - 1, 0, 6);
- }
- next.lastFeedback = t(
- inspection.kind === 'anomaly' ? 'ui.inspectionCorrectAnomaly' : 'ui.inspectionCorrectNormal',
- );
- next = appendLog(next, 'success', next.lastFeedback);
- } else {
- next.streak = 0;
- next.stability = clamp((next.stability ?? 0) - 12, 0, 100);
- next.anomalyLevel = clamp((next.anomalyLevel ?? 0) + 1, 0, 6);
- next.lastFeedback = t('ui.inspectionWrong');
- next = appendLog(next, 'danger', next.lastFeedback);
- }
-
- if (tutorialStep === 3) next.tutorialStep = 4;
- return { state: checkFailure(next), accepted: true, correct };
-}
-
-function expireInspection(state) {
- if (state.gameOver) return { state, timedOut: false };
- const inspection = state.inspection;
- if (!inspection || inspection.status !== 'pending' || (state.elapsed ?? 0) < inspection.expiresAt) {
- return { state, timedOut: false };
- }
-
- let next = cloneState(state);
- const tutorialStep = Number(next.tutorialStep || 0);
- const guidedTimeout = (tutorialStep === 0 && inspection.kind === 'normal')
- || (tutorialStep === 1 && inspection.kind === 'anomaly');
- next.inspection = {
- ...inspection,
- status: 'expired',
- choice: null,
- correct: false,
- resolvedAt: next.elapsed ?? 0,
- };
- if (guidedTimeout) {
- next.tutorialStep = tutorialStep + 1;
- next.lastFeedback = t('ui.wrongTutorial');
- next = appendLog(next, 'info', next.lastFeedback);
- return { state: next, timedOut: true, coached: true };
- }
- next.decisionsWrong = (next.decisionsWrong ?? 0) + 1;
- next.streak = 0;
- next.stability = clamp((next.stability ?? 0) - 8, 0, 100);
- if (Number(next.tutorialStep || 0) === 3) next.tutorialStep = 4;
- next.lastFeedback = t('ui.inspectionTimeout');
- next = appendLog(next, 'warn', next.lastFeedback);
- return { state: checkFailure(next), timedOut: true };
-}
-
-
-// --- src/events.js ---
-
-
-
-/**
- * 从皮肤数据动态构建异常事件数组
- */
-function createAnomaly(skinDef) {
- return {
- id: skinDef.id,
- title: skinDef.title,
- severity: skinDef.severity,
- monitor: skinDef.monitor,
- adHint: skinDef.adHint,
- effects: skinDef.effects || {},
- apply(state) {
- const next = cloneState(state);
- const effects = skinDef.effects || {};
- // 计算难度倍率
- const elapsed = state.elapsed || 0;
- const diffScale = CONFIG.anomaly.difficultyScale || 1;
- const diffInterval = CONFIG.anomaly.difficultyInterval || 10;
- const multiplier = Math.pow(diffScale, elapsed / diffInterval);
-
- for (const [field, value] of Object.entries(effects)) {
- let adjusted = value;
- // 负数效果(消耗类)才乘难度系数
- if (typeof value === 'number' && value < 0) {
- adjusted = Math.round(value * multiplier);
- } else if (typeof value === 'string' && isDeltaEffect(value) && parseInt(value, 10) < 0) {
- const num = parseInt(value, 10);
- adjusted = `${Math.round(num * multiplier)}`;
- }
- if (typeof adjusted === 'number' && shouldAddNumericEffect(field, adjusted)) {
- next[field] = clamp((next[field] ?? 0) + adjusted, 0, 100);
- } else if (typeof adjusted === 'string' && isDeltaEffect(adjusted)) {
- next[field] = Math.min(CONFIG.bounds.maxFloor, (next[field] ?? 0) + parseInt(adjusted, 10));
- } else {
- next[field] = adjusted;
- }
- }
- next.anomalyLevel = clamp(next.anomalyLevel, 0, 6);
- next.stability = clamp(next.stability, 0, 100);
- next.power = clamp(next.power, 0, 100);
- next.activeAnomaly = skinDef.id;
- next.monitor = skinDef.monitor;
- return next;
- },
- };
-}
-
-function isDeltaEffect(value) {
- return /^[+-]\d+$/.test(value);
-}
-
-function shouldAddNumericEffect(field, value) {
- if (value < 0) return true;
- return field === 'power' || field === 'stability' || field === 'anomalyLevel';
-}
-
-/** 当前皮肤生成的异常事件列表 */
-const ANOMALIES = getAnomalies().map(createAnomaly);
-
-function findAnomaly(id) {
- return ANOMALIES.find((event) => event.id === id);
-}
-
-function applyAnomaly(state, id) {
- const event = findAnomaly(id);
- if (!event) throw new Error(`Unknown anomaly: ${id}`);
- let next = event.apply(state);
- next.lastAdHint = event.adHint;
- // 复盘统计
- next.anomaliesTriggeredTotal = (next.anomaliesTriggeredTotal ?? 0) + 1;
- next.maxAnomalySeverity = Math.max(next.maxAnomalySeverity ?? 0, event.severity);
- // 添加关联隐藏日志(不重复)
- const raw = getHiddenLog(id);
- if (raw && !next.hiddenLogs.some(h => h.id === id + '_log')) {
- next.hiddenLogs.push({ id: id + '_log', title: raw.title, content: raw.content, locked: true });
- next = appendLog(next, 'info', t('ui.hiddenLogCaptured', { title: raw.title }));
- }
- next = appendLog(next, event.severity >= 3 ? 'danger' : 'warn', t('ui.anomalyEventLog', {
- title: event.title,
- hint: event.adHint,
- }));
- return { event, state: checkFailure(next) };
-}
-
-function pickNextAnomaly(state, random = Math.random) {
- const firstRunPool = (state.anomaliesTriggeredTotal ?? 0) === 0
- ? ANOMALIES.filter(event => event.severity <= CONFIG.anomaly.firstMaxSeverity)
- : ANOMALIES;
- const pool = firstRunPool.length > 0 ? firstRunPool : ANOMALIES;
- const pressure = Math.min(pool.length - 1, Math.floor(state.anomalyLevel / CONFIG.anomaly.pressureDivisor));
- const index = Math.min(pool.length - 1, Math.floor(random() * pool.length + pressure) % pool.length);
- return pool[index];
-}
-/** @deprecated 请使用 getHiddenLog() 代替 */
-const _buildHiddenLogsMap = () => {
- const map = {};
- const anomalies = getAnomalies();
- for (const a of anomalies) {
- const hl = getHiddenLog(a.id);
- if (hl) {
- map[a.id] = { id: `${a.id}_log`, title: hl.title, content: hl.content };
- }
- }
- return map;
-};
-
-const HIDDEN_LOGS = _buildHiddenLogsMap();
-
-
-// --- src/actions.js ---
-
-
-
-
-const ACTIONS = {
- openDoor(state) {
- if (state.moving) return fail(state, t('actionFailMessages.openDoor_moving'));
- let next = cloneState(state);
- next.door = 'open';
- next.transition = {
- kind: 'doorOpening', duration: 1, remaining: 1,
- fromDoor: state.door, toDoor: 'open',
- };
- next.monitor = t('monitor.actions.openDoor', { floor: next.floor });
- next = appendLog(next, 'info', t('actionLogMessages.openDoor', { floor: next.floor }));
- return ok(next, t('actionFeedback.openDoor'));
- },
-
- closeDoor(state) {
- let next = cloneState(state);
- next.door = 'closed';
- next.transition = {
- kind: 'doorClosing', duration: 1, remaining: 1,
- fromDoor: state.door, toDoor: 'closed',
- };
- next.monitor = t('monitor.actions.closeDoor');
- next = appendLog(next, 'info', t('actionLogMessages.closeDoor'));
- return ok(next, t('actionFeedback.closeDoor'));
- },
-
- moveUp(state) {
- if (state.door !== 'closed') return fail(state, t('actionFailMessages.moveUp_doorNotClosed'));
- let next = cloneState(state);
- const a = CONFIG.actions.moveUp;
- const fromFloor = next.floor;
- next.floor += 1;
- next.moving = true;
- next.direction = 'up';
- next.transition = {
- kind: 'movingUp', duration: 2, remaining: 2,
- fromFloor, toFloor: next.floor,
- };
- next.power = clamp(next.power - a.powerCost, 0, 100);
- next.stability = clamp(next.stability - a.stabilityCost, 0, 100);
- next.monitor = t('monitor.actions.moveUp', { floor: next.floor });
- next = appendLog(next, 'info', t('actionLogMessages.moveUp', { floor: next.floor }));
- return ok(checkFailure(next), t('actionFeedback.moveUp'));
- },
-
- moveDown(state) {
- if (state.door !== 'closed') return fail(state, t('actionFailMessages.moveDown_doorNotClosed'));
- let next = cloneState(state);
- const a = CONFIG.actions.moveDown;
- const fromFloor = next.floor;
- next.floor -= 1;
- next.moving = true;
- next.direction = 'down';
- next.transition = {
- kind: 'movingDown', duration: 2, remaining: 2,
- fromFloor, toFloor: next.floor,
- };
- next.power = clamp(next.power - a.powerCost, 0, 100);
- next.stability = clamp(next.stability - a.stabilityCost, 0, 100);
- next.monitor = t('monitor.actions.moveDown', { floor: next.floor });
- next = appendLog(next, 'info', t('actionLogMessages.moveDown', { floor: next.floor }));
- return ok(checkFailure(next), t('actionFeedback.moveDown'));
- },
-
- emergencyStop(state) {
- let next = cloneState(state);
- const es = CONFIG.actions.emergencyStop;
- if (next.activeAnomaly === 'stop_failure') {
- next.anomalyLevel = clamp(next.anomalyLevel + 1, 0, 6);
- next.stability = clamp(next.stability - es.stabilityCostOnFailure, 0, 100);
- next = appendLog(next, 'danger', t('actionLogMessages.emergencyStop_fail'));
- return fail(checkFailure(next), t('actionFeedback.emergencyStop_fail'));
- }
- next.moving = false;
- next.direction = 'idle';
- next.transition = { kind: 'emergencyStop', duration: 1, remaining: 1 };
- next.stability = clamp(next.stability - es.stabilityCost, 0, 100);
- next.monitor = t('monitor.actions.emergencyStop');
- next = appendLog(next, 'warn', t('actionLogMessages.emergencyStop'));
- return ok(checkFailure(next), t('actionFeedback.emergencyStop'));
- },
-
- restartSystem(state) {
- let next = cloneState(state);
- const rs = CONFIG.actions.restartSystem;
- next.anomalyLevel = Math.max(0, next.anomalyLevel - rs.anomalyLevelReduce);
- next.stability = clamp(next.stability + rs.stabilityRestore, 0, 100);
- next.power = clamp(next.power - rs.powerCost, 0, 100);
- next.moving = false;
- next.direction = 'idle';
- next.transition = { kind: 'systemReboot', duration: 2, remaining: 2 };
- next.monitor = t('monitor.actions.restartSystem');
- next = appendLog(next, 'warn', t('actionLogMessages.restartSystem', { cost: rs.powerCost }));
- return ok(checkFailure(next), t('actionFeedback.restartSystem'));
- },
-
- inspectLog(state) {
- let next = appendLog(state, 'info', t('actionLogMessages.inspectLog'));
- const lockedCount = next.hiddenLogs.filter(h => h.locked).length;
- if (lockedCount > 0) {
- next = appendLog(next, 'ad', t('actionLogMessages.inspectLog_hiddenRecords', { count: lockedCount }));
- }
- return ok(next, t('actionFeedback.inspectLog'));
- },
-
- unlockHiddenLog(state) {
- // 找到第一条仍锁定的隐藏日志
- const locked = state.hiddenLogs.find(h => h.locked);
- if (!locked) {
- return fail(state, t('actionFeedback.unlockHiddenLog_noLocked'));
- }
- const unlocked = state.adHintsUsed;
- if (unlocked >= CONFIG.hiddenLogs.maxUnlockedPerRun) {
- return fail(state, t('actionFeedback.unlockHiddenLog_limit', { count: unlocked }));
- }
- let next = cloneState(state);
- const idx = next.hiddenLogs.findIndex(h => h.id === locked.id);
- if (idx !== -1) {
- next.hiddenLogs[idx] = { ...next.hiddenLogs[idx], locked: false };
- }
- next.adHintsUsed += 1;
- next = appendLog(next, 'ad', t('actionLogMessages.unlockHiddenLog_ok'));
- next.monitor = t('ui.decodeMonitor', { title: locked.title });
- return ok(next, t('ui.unlockResult', { title: locked.title }));
- },
-};
-
-function ok(state, message) {
- return { ok: true, state, message };
-}
-
-function fail(state, message) {
- const next = appendLog(state, 'warn', message);
- return { ok: false, state: next, message };
-}
-
-function performAction(state, actionId) {
- const action = ACTIONS[actionId];
- if (!action) return fail(state, t('actionFailMessages.unknownAction', { actionId }));
- if (state.gameOver && actionId !== 'inspectLog') return fail(state, t('actionFailMessages.gameOver'));
- const hasSpecificDoorFailure = ['moveUp', 'moveDown'].includes(actionId) && state.door !== 'closed';
- if (state.transition && !hasSpecificDoorFailure && !['emergencyStop', 'inspectLog', 'unlockHiddenLog'].includes(actionId)) {
- return fail(state, t('actionFailMessages.systemBusy'));
- }
- const activeAnomaly = state.activeAnomaly;
- const resolutionAction = activeAnomaly ? getAnomalyResolutionAction(activeAnomaly) : null;
- const preservesSpecificStopFailure = activeAnomaly === 'stop_failure' && actionId === 'emergencyStop';
- if (activeAnomaly && resolutionAction && resolutionAction !== actionId && !preservesSpecificStopFailure) {
- let next = cloneState(state);
- if (Number(next.tutorialStep || 0) === 2) {
- next.lastFeedback = t('ui.wrongTreatmentTutorial');
- next = appendLog(next, 'info', next.lastFeedback);
- return { ok: false, state: next, message: next.lastFeedback, coached: true };
- }
- next.stability = clamp((next.stability ?? 0) - 6, 0, 100);
- next.anomalyLevel = clamp((next.anomalyLevel ?? 0) + 1, 0, 6);
- next.streak = 0;
- next.lastFeedback = t('ui.wrongTreatment');
- next = appendLog(next, 'danger', next.lastFeedback);
- return { ok: false, state: checkFailure(next), message: next.lastFeedback };
- }
-
- const result = action(state);
- if (!result.ok || !activeAnomaly || resolutionAction !== actionId) {
- return result;
- }
-
- let next = cloneState(result.state);
- next.activeAnomaly = null;
- next.score = (next.score ?? 0) + 150;
- if (Number(next.tutorialStep || 0) === 2) next.tutorialStep = 3;
- if (result.state.activeAnomaly === activeAnomaly) {
- next.anomalyLevel = Math.min(next.anomalyLevel, Math.max(0, state.anomalyLevel - 1));
- }
- next.monitor = t('ui.anomalyResolvedMonitor');
- const message = t('ui.anomalyResolved', { action: actionLabel(actionId) });
- next.lastFeedback = message;
- next = appendLog(next, 'success', message);
- return ok(checkFailure(next), message);
-}
-
-const ACTION_IDS = [
- 'openDoor',
- 'closeDoor',
- 'moveUp',
- 'moveDown',
- 'emergencyStop',
- 'restartSystem',
- 'inspectLog',
- 'unlockHiddenLog',
-];
-
-function getAvailableActions() {
- return ACTION_IDS.map(id => ({ id, label: actionLabel(id) }));
-}
-
-
-// --- src/uiLabels.js ---
-
-function getDomLabels() {
- const skin = getSkin();
- const status = skin.statusLabels || {};
- const canvas = skin.canvasLabels || {};
-
- return {
- countdown: canvas.countdown || '值守倒计时',
- statusPanel: status.panelTitle || '电梯状态',
- monitorPanel: canvas.monitorPanel || '监控画面',
- actionPanel: canvas.actionPanel || '操作面板',
- logPanel: canvas.logPanel || '系统日志',
- forceAnomaly: canvas.forceAnomaly || t('ui.triggerTest'),
- failureTitle: canvas.failureTitle || '系统崩溃',
- failureEyebrow: canvas.failureEyebrow || 'SYSTEM FAILURE',
- monitorSignal: {
- stable: canvas.monitorSignalStable || 'SIGNAL: STABLE',
- unstable: canvas.monitorSignalUnstable || 'SIGNAL: UNSTABLE',
- corrupted: canvas.monitorSignalCorrupted || 'SIGNAL: CORRUPTED',
- },
- monitorThreat: (level) => (canvas.monitorThreat || 'THREAT: {level}').replace('{level}', level),
- failureMetrics: [
- { key: 'power', label: status.power || '电源' },
- { key: 'stability', label: canvas.failureMetricStability || status.stability || '稳定度' },
- { key: 'anomalyLevel', label: canvas.failureMetricAnomaly || status.anomalyLevel || '异常' },
- { key: 'remaining', label: canvas.failureMetricRemaining || '剩余' },
- ],
- revive: t('ui.viewAd'),
- restart: t('ui.restart'),
- revealTruth: t('ui.revealTruth'),
- start: {
- title: t('ui.startTitle'),
- copy: t('ui.startCopy'),
- checklist: t('ui.startChecklist').split('\n').filter(Boolean),
- failureRulesTitle: t('ui.startFailureRulesTitle'),
- failureRules: t('ui.startFailureRules').split('\n').filter(Boolean),
- button: t('ui.startButton'),
- },
- status: {
- floor: status.floor || '楼层',
- door: status.door || '门状态',
- direction: status.direction || '方向',
- passengers: status.passengers || '乘客',
- power: status.power || '电源',
- stability: status.stability || '稳定度',
- anomalyLevel: status.anomalyLevel || '异常等级',
- reviveCount: status.reviveCount || '广告复活',
- adHintsCount: status.adHintsCount || '加密解码',
- hiddenLogsCount: status.hiddenLogsCount || '待解码',
- },
- };
-}
-
-function getDecodedMonitorText(hiddenLog) {
- return `${t('ui.decodePrefix')} ${hiddenLog.title}\n${hiddenLog.content}`;
-}
-
-function getDoorLabel(value) {
- const labels = getSkin().doorLabels || { open: '开启', closed: '关闭' };
- return labels[value] || value;
-}
-
-function getDirectionLabel(value) {
- const labels = getSkin().directionLabels || { up: '上行', down: '下行', idle: '待机' };
- return labels[value] || value;
-}
-
-
-// --- src/runtimeSession.js ---
-
-
-function createRuntimeSession() {
- return {
- state: createInitialState(),
- nextAnomalyAt: CONFIG.anomaly.firstTriggerAt,
- };
-}
-
-function restartRuntimeSession(previousSession = null) {
- const session = createRuntimeSession();
- const previous = previousSession?.state;
- if (!previous) return session;
-
- session.state.consecutiveFailures = previous.consecutiveFailures || 0;
- session.state.fakeEndingCooldownRemaining = previous.fakeEndingCooldownRemaining || 0;
- session.state.fakeEndingCount = previous.fakeEndingCount || 0;
- session.state.tutorialStep = Math.min(4, previous.tutorialStep || 0);
- session.state.fakeEndingTriggered = false;
- session.state.fakeEndingUnlocked = false;
- return session;
-}
-
-function scheduleNextAnomalyAfterTrigger(elapsed, random = Math.random) {
- const cd = CONFIG.anomaly;
- const span = cd.cooldownMax - cd.cooldownMin + 1;
- return elapsed + cd.cooldownMin + Math.floor(random() * span);
-}
-
-function scheduleNextAnomalyAfterRevive(elapsed) {
- return elapsed + CONFIG.anomaly.cooldownMin;
-}
-
-
-// --- src/rewardGuard.js ---
-function shouldApplyReward(meta, currentRunToken, kind, state) {
- if (meta?.context?.runToken !== currentRunToken || !state) return false;
-
- if (kind === 'decode') {
- return !state.gameOver && Boolean(state.hiddenLogs?.some(entry => entry.locked));
- }
-
- if (kind === 'revive') {
- return state.gameOver === true
- && state.result === 'failure'
- && !state.fakeEndingTriggered;
- }
-
- if (kind === 'truth') {
- return state.gameOver === true
- && state.result === 'failure'
- && state.fakeEndingTriggered === true
- && !state.fakeEndingUnlocked;
- }
-
- return false;
-}
-
-
-// --- src/firstRunGuidance.js ---
-function getOperatorCue(state, nextAnomalyAt) {
- const elapsed = Math.max(0, Math.floor(state?.elapsed ?? 0));
- const firstAnomalySeen = (state?.anomaliesTriggeredTotal ?? 0) > 0;
-
- if (state?.gameOver) {
- return '先看本轮结果,再决定复活或重新值守。';
- }
-
- if (state?.activeAnomaly) {
- return '异常已封锁:系统正在自动处置。';
- }
-
- if (!firstAnomalySeen) {
- const seconds = Math.max(0, Math.ceil((nextAnomalyAt ?? elapsed) - elapsed));
- return `首班 ${seconds} 秒内到达:三项一致就放行。`;
- }
-
- return '对得上就放行,对不上就封锁。';
-}
-
-
-// --- src/analytics.js ---
-const ANALYTICS_EVENTS = Object.freeze([
- 'game_start',
- 'game_over',
- 'revive_ad_start',
- 'revive_ad_reward',
- 'hidden_log_ad_start',
- 'hidden_log_unlock',
- 'fake_ending_trigger',
- 'action_click',
- 'anomaly_trigger',
-]);
-
-const EVENT_SET = new Set(ANALYTICS_EVENTS);
-
-function createConsoleAnalyticsSink(logger = console) {
- return (event) => {
- logger.log('[analytics]', event.name, event);
- };
-}
-
-let analyticsSink = createConsoleAnalyticsSink();
-
-function setAnalyticsSink(sink) {
- if (typeof sink !== 'function') {
- throw new TypeError('analytics sink must be a function');
- }
- analyticsSink = sink;
-}
-
-function resetAnalyticsSink() {
- analyticsSink = createConsoleAnalyticsSink();
-}
-
-function trackEvent(name, payload = {}, options = {}) {
- if (!EVENT_SET.has(name)) {
- throw new Error(`Unknown analytics event: ${name}`);
- }
-
- const now = options.now || Date.now;
- const event = {
- name,
- ts: now(),
- ...payload,
- };
-
- analyticsSink(event);
- return event;
-}
-
-
-// --- src/archive.js ---
-// archive.js — cross-session anomaly archive (localStorage-backed).
-// Survives page reloads and browser restarts on the same device.
-
-const STORAGE_KEY = 'minigame_archive_v1';
-
-const DEFAULT_SKIN_PROGRESS = {
- sessionsPlayed: 0,
- totalAnomaliesTriggered: 0,
- totalLogsUnlocked: 0,
- encounteredAnomalies: {}, // id → count
- unlockedLogs: {}, // log id → true
- highestSeverity: 0,
-};
-
-const DEFAULT = {
- sessionsPlayed: 0,
- totalAnomaliesTriggered: 0,
- totalLogsUnlocked: 0,
- encounteredAnomalies: {}, // id → count
- unlockedLogs: {}, // log id → true
- highestSeverity: 0,
- skins: {}, // skinId → DEFAULT_SKIN_PROGRESS
-};
-
-function cloneDefaultSkinProgress() {
- return structuredClone(DEFAULT_SKIN_PROGRESS);
-}
-
-function normalizeArchive(raw) {
- const archive = { ...structuredClone(DEFAULT), ...(raw || {}) };
- archive.encounteredAnomalies ||= {};
- archive.unlockedLogs ||= {};
- archive.skins ||= {};
- for (const [skinId, progress] of Object.entries(archive.skins)) {
- archive.skins[skinId] = {
- ...cloneDefaultSkinProgress(),
- ...(progress || {}),
- encounteredAnomalies: { ...(progress?.encounteredAnomalies || {}) },
- unlockedLogs: { ...(progress?.unlockedLogs || {}) },
- };
- }
- return archive;
-}
-
-function getOrCreateSkinProgress(archive, skinId) {
- if (!skinId) return null;
- if (!archive.skins[skinId]) archive.skins[skinId] = cloneDefaultSkinProgress();
- return archive.skins[skinId];
-}
-
-/** @returns {typeof DEFAULT} */
-function loadArchive() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return structuredClone(DEFAULT);
- return normalizeArchive(JSON.parse(raw));
- } catch {
- return structuredClone(DEFAULT);
- }
-}
-
-function saveArchive(archive) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(archive));
- } catch {
- // quota exceeded or private browsing — silently skip
- }
-}
-
-/**
- * Merge one session's results into the archive.
- * @param {object} sessionSummary
- * @param {string} [sessionSummary.skinId]
- * @param {number} sessionSummary.anomaliesTriggeredTotal
- * @param {number} sessionSummary.maxAnomalySeverity
- * @param {string[]} sessionSummary.anomalyIds — all anomaly IDs triggered this session
- * @param {string[]} sessionSummary.unlockedLogIds — hidden log IDs unlocked this session
- */
-function commitSessionToArchive(sessionSummary) {
- const archive = loadArchive();
- const skinProgress = getOrCreateSkinProgress(archive, sessionSummary.skinId);
- const unlockedLogIds = sessionSummary.unlockedLogIds || [];
-
- archive.sessionsPlayed += 1;
- archive.totalAnomaliesTriggered += sessionSummary.anomaliesTriggeredTotal || 0;
- archive.totalLogsUnlocked += unlockedLogIds.length;
- archive.highestSeverity = Math.max(archive.highestSeverity, sessionSummary.maxAnomalySeverity || 0);
-
- if (skinProgress) {
- skinProgress.sessionsPlayed += 1;
- skinProgress.totalAnomaliesTriggered += sessionSummary.anomaliesTriggeredTotal || 0;
- skinProgress.totalLogsUnlocked += unlockedLogIds.length;
- skinProgress.highestSeverity = Math.max(skinProgress.highestSeverity, sessionSummary.maxAnomalySeverity || 0);
- }
-
- for (const id of sessionSummary.anomalyIds || []) {
- archive.encounteredAnomalies[id] = (archive.encounteredAnomalies[id] || 0) + 1;
- if (skinProgress) {
- skinProgress.encounteredAnomalies[id] = (skinProgress.encounteredAnomalies[id] || 0) + 1;
- }
- }
- for (const id of unlockedLogIds) {
- archive.unlockedLogs[id] = true;
- if (skinProgress) skinProgress.unlockedLogs[id] = true;
- }
- saveArchive(archive);
- return archive;
-}
-
-function getArchiveSkinProgress(archive, skinId, anomalyCatalog = []) {
- const normalized = normalizeArchive(archive);
- const progress = normalized.skins[skinId] || cloneDefaultSkinProgress();
- const totalAnomalies = anomalyCatalog.length;
- const encounteredCount = Object.keys(progress.encounteredAnomalies).length;
- const unlockedLogsCount = Object.keys(progress.unlockedLogs).length;
-
- return {
- skinId,
- sessionsPlayed: progress.sessionsPlayed,
- encounteredCount,
- unlockedLogsCount,
- totalAnomalies,
- completionRate: totalAnomalies ? encounteredCount / totalAnomalies : 0,
- logCompletionRate: totalAnomalies ? unlockedLogsCount / totalAnomalies : 0,
- highestSeverity: progress.highestSeverity,
- totalAnomaliesTriggered: progress.totalAnomaliesTriggered,
- };
-}
-
-
-// --- src/audio.js ---
-/**
- * audio.js — 程序化音效(Web Audio API,无需外部文件)
- *
- * 所有声音通过 OscillatorNode + GainNode 实时合成,
- * 初始化为惰性加载,首次用户交互时才会创建 AudioContext。
- */
-
-let ctx = null;
-let muted = false;
-
-const AUDIO_LAYERS = Object.freeze({
- button: Object.freeze({ kind: 'beep', freq: 800, duration: 0.06, type: 'square', volume: 0.06 }),
- success: Object.freeze({ kind: 'beep', freq: 1000, duration: 0.1, type: 'sine', volume: 0.07 }),
- error: Object.freeze({ kind: 'beep', freq: 300, duration: 0.18, type: 'sawtooth', volume: 0.07 }),
- anomaly: Object.freeze({ kind: 'sweep', startFreq: 200, endFreq: 80, duration: 0.45, type: 'sawtooth', volume: 0.08 }),
- warning: Object.freeze({ kind: 'sweep', startFreq: 600, endFreq: 200, duration: 0.25, type: 'square', volume: 0.06 }),
- failure: Object.freeze({ kind: 'sweep', startFreq: 150, endFreq: 30, duration: 0.8, type: 'sawtooth', volume: 0.1 }),
- revive: Object.freeze({ kind: 'sweep', startFreq: 200, endFreq: 1200, duration: 0.5, type: 'sine', volume: 0.08 }),
- restart: Object.freeze({ kind: 'sequence', steps: [
- Object.freeze({ at: 0, kind: 'beep', freq: 600, duration: 0.08, type: 'sine', volume: 0.06 }),
- Object.freeze({ at: 100, kind: 'beep', freq: 800, duration: 0.1, type: 'sine', volume: 0.06 }),
- ] }),
-});
-
-function setAudioMuted(value) {
- muted = Boolean(value);
- return muted;
-}
-
-function isAudioMuted() {
- return muted;
-}
-
-function toggleAudioMuted() {
- return setAudioMuted(!muted);
-}
-
-function getAudioLayer(layerId) {
- return AUDIO_LAYERS[layerId] ?? null;
-}
-
-function playLayer(layerId) {
- if (muted) return false;
- const layer = getAudioLayer(layerId);
- if (!layer) return false;
- if (layer.kind === 'beep') {
- beep(layer.freq, layer.duration, layer.type, layer.volume);
- return true;
- }
- if (layer.kind === 'sweep') {
- sweep(layer.startFreq, layer.endFreq, layer.duration, layer.type, layer.volume);
- return true;
- }
- if (layer.kind === 'sequence') {
- for (const step of layer.steps) {
- window.setTimeout(() => {
- if (!muted && step.kind === 'beep') beep(step.freq, step.duration, step.type, step.volume);
- if (!muted && step.kind === 'sweep') sweep(step.startFreq, step.endFreq, step.duration, step.type, step.volume);
- }, step.at);
- }
- return true;
- }
- return false;
-}
-
-function getContext() {
- if (!ctx) {
- ctx = new (window.AudioContext || window.webkitAudioContext)();
- }
- // 某些浏览器在 user gesture 后需要 resume
- if (ctx.state === 'suspended') {
- ctx.resume().catch(() => {});
- }
- return ctx;
-}
-
-/**
- * 播放一个简单的单频音
- * @param {number} freq - 频率 Hz
- * @param {number} duration - 持续秒
- * @param {string} type - 波形类型
- * @param {number} volume - 音量 0-1
- */
-function beep(freq, duration, type = 'square', volume = 0.08) {
- try {
- const ac = getContext();
- const osc = ac.createOscillator();
- const gain = ac.createGain();
- osc.type = type;
- osc.frequency.setValueAtTime(freq, ac.currentTime);
- gain.gain.setValueAtTime(volume, ac.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration);
- osc.connect(gain);
- gain.connect(ac.destination);
- osc.start(ac.currentTime);
- osc.stop(ac.currentTime + duration);
- } catch {
- // 静默失败 — 音效不是关键功能
- }
-}
-
-/**
- * 播放一个扫频音(用于异常/警报)
- */
-function sweep(startFreq, endFreq, duration, type = 'sawtooth', volume = 0.06) {
- try {
- const ac = getContext();
- const osc = ac.createOscillator();
- const gain = ac.createGain();
- osc.type = type;
- osc.frequency.setValueAtTime(startFreq, ac.currentTime);
- osc.frequency.exponentialRampToValueAtTime(endFreq, ac.currentTime + duration);
- gain.gain.setValueAtTime(volume, ac.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration);
- osc.connect(gain);
- gain.connect(ac.destination);
- osc.start(ac.currentTime);
- osc.stop(ac.currentTime + duration);
- } catch {
- // 静默失败
- }
-}
-
-/** 按钮点击 — 短促的咔嗒声 */
-function playClick() {
- return playLayer('button');
-}
-
-/** 操作成功 — 确认音 */
-function playSuccess() {
- return playLayer('success');
-}
-
-/** 操作失败 — 拒绝音 */
-function playFail() {
- return playLayer('error');
-}
-
-/** 异常触发 — 低频警报扫频 */
-function playAnomaly() {
- return playLayer('anomaly');
-}
-
-/** 稳定度/电源危险 — 短促警告 */
-function playWarning() {
- return playLayer('warning');
-}
-
-/** 系统崩溃 — 低沉衰减 */
-function playCrash() {
- return playLayer('failure');
-}
-
-/** 广告复活 — 上升恢复音 */
-function playRevive() {
- return playLayer('revive');
-}
-
-/** 游戏重启 — 重置音 */
-function playRestart() {
- return playLayer('restart');
-}
-
-
-// --- platform/platform.js ---
-/**
- * platform.js — 平台抽象层
- *
- * 统一浏览器/微信小游戏/抖音小游戏的 API 差异。
- * 游戏引擎只依赖此模块,不直接调用平台 API。
- */
-
-
-// ── 环境检测 ──
-const env = (() => {
- if (typeof wx !== 'undefined' && wx && typeof wx.createRewardedVideoAd === 'function') {
- return 'wechat';
- }
- if (typeof tt !== 'undefined' && tt && typeof tt.createRewardedVideoAd === 'function') {
- return 'douyin';
- }
- return 'browser';
-})();
-
-// ── Canvas ──
-let mainCanvas = null;
-let mainCtx = null;
-
-/**
- * 获取/创建主画布
- */
-function getCanvas(width = 750, height = 1334) {
- if (mainCanvas) return mainCanvas;
-
- if (env === 'wechat') {
- mainCanvas = wx.createCanvas();
- mainCanvas.width = width;
- mainCanvas.height = height;
- } else if (env === 'douyin') {
- mainCanvas = tt.createCanvas();
- mainCanvas.width = width;
- mainCanvas.height = height;
- } else {
- // 浏览器模式 — 使用 DOM 渲染,canvas 仅作为 fallback
- mainCanvas = document.createElement('canvas');
- mainCanvas.width = width;
- mainCanvas.height = height;
- mainCanvas.style.display = 'none';
- document.body.appendChild(mainCanvas);
- }
-
- mainCtx = mainCanvas.getContext('2d');
- return mainCanvas;
-}
-
-function getContext() {
- if (!mainCtx) getCanvas();
- return mainCtx;
-}
-
-// ── 广告 ──
-let adInstances = {};
-
-function createHostRewardedAd(hostApi, adUnitId, callbacks, label) {
- const { onReward, onError } = callbacks;
- let activeAttempt = null;
- let attemptSequence = 0;
-
- const settle = (attempt, { rewarded = false, error = null } = {}) => {
- if (!attempt || attempt.settled) return;
- attempt.settled = true;
- if (activeAttempt === attempt) activeAttempt = null;
- const meta = { attemptId: attempt.id, context: attempt.context };
- if (error) console.warn(`[ad:${label}] error:`, error);
- if (rewarded || (error && !CONFIG.releaseMode)) onReward?.(meta);
- if (error) onError?.(error, meta);
- attempt.ad.offClose?.(attempt.closeHandler);
- attempt.ad.offError?.(attempt.errorHandler);
- attempt.ad.destroy?.();
- };
-
- return (context = null) => {
- if (activeAttempt && !activeAttempt.settled) return Promise.resolve();
- const ad = hostApi.createRewardedVideoAd({ adUnitId });
- const attempt = {
- id: ++attemptSequence,
- context,
- settled: false,
- ad,
- closeHandler: null,
- errorHandler: null,
- };
- attempt.closeHandler = (res) => settle(attempt, { rewarded: Boolean(res?.isEnded) });
- attempt.errorHandler = (error) => settle(attempt, { error });
- activeAttempt = attempt;
- ad.onClose(attempt.closeHandler);
- ad.onError(attempt.errorHandler);
-
- return Promise.resolve()
- .then(() => ad.show())
- .catch((showError) => {
- if (attempt.settled) return undefined;
- return Promise.resolve()
- .then(() => ad.load?.())
- .then(() => ad.show())
- .catch((loadError) => settle(attempt, { error: loadError || showError }));
- });
- };
-}
-
-/**
- * 创建激励视频广告
- * @param {string} adUnitId - 广告位 ID
- * @param {object} callbacks - { onReward, onError }
- * @returns {function} show() 函数
- */
-function createRewardedAd(adUnitId, callbacks = {}) {
- if (adInstances[adUnitId]) return adInstances[adUnitId];
-
- const { onReward, onError } = callbacks;
-
- if (env === 'wechat') {
- const show = createHostRewardedAd(wx, adUnitId, callbacks, 'wechat');
- adInstances[adUnitId] = show;
- return show;
- }
-
- if (env === 'douyin') {
- const show = createHostRewardedAd(tt, adUnitId, callbacks, 'douyin');
- adInstances[adUnitId] = show;
- return show;
- }
-
- // 浏览器模式 — 模拟广告;同样回传发起时上下文,供运行时拒绝陈旧奖励。
- let browserAttempt = null;
- let browserAttemptSequence = 0;
- const show = (context = null) => {
- if (browserAttempt && !browserAttempt.settled) return Promise.resolve();
- browserAttempt = { id: ++browserAttemptSequence, context, settled: false };
- const activeAttempt = browserAttempt;
- return new Promise((resolve) => {
- console.log('[ad] 模拟广告播放中...');
- setTimeout(() => {
- activeAttempt.settled = true;
- console.log('[ad] 模拟广告完成');
- onReward?.({ attemptId: activeAttempt.id, context: activeAttempt.context });
- resolve();
- }, CONFIG?.adContent?.adVideoDuration ?? 2000);
- });
- };
- adInstances[adUnitId] = show;
- return show;
-}
-
-// ── 存储 ──
-function setStorage(key, value) {
- try {
- const data = JSON.stringify(value);
- if (env === 'wechat') wx.setStorageSync(key, data);
- else if (env === 'douyin') tt.setStorageSync(key, data);
- else localStorage.setItem(key, data);
- } catch (e) {
- console.warn('[storage] set failed:', e);
- }
-}
-
-function getStorage(key, fallback = null) {
- try {
- let data;
- if (env === 'wechat') data = wx.getStorageSync(key);
- else if (env === 'douyin') data = tt.getStorageSync(key);
- else data = localStorage.getItem(key);
- return data ? JSON.parse(data) : fallback;
- } catch {
- return fallback;
- }
-}
-
-// ── 事件绑定 ──
-function onTouch(canvas, handler) {
- const cb = (e) => {
- const touch = e.touches?.[0] || e;
- const rect = canvas.getBoundingClientRect?.();
- const x = (touch.clientX || touch.x) - (rect?.left || 0);
- const y = (touch.clientY || touch.y) - (rect?.top || 0);
- handler(x, y, e);
- };
-
- if (env === 'wechat') {
- wx.onTouchStart(cb);
- } else if (env === 'douyin') {
- tt.onTouchStart(cb);
- } else {
- canvas.addEventListener('click', cb);
- }
-}
-
-// ── 信息 ──
-function getSystemInfo() {
- if (env === 'wechat') return wx.getSystemInfoSync();
- if (env === 'douyin') return tt.getSystemInfoSync();
- return {
- windowWidth: window.innerWidth,
- windowHeight: window.innerHeight,
- pixelRatio: window.devicePixelRatio || 1,
- platform: 'browser',
- };
-}
-
-
-
-// --- src/game.js ---
-
-
-
-
-
-
-
-
-
-
-
-const root = document.querySelector('.console-shell');
-const debugMode = new URLSearchParams(window.location.search).get('debug') === '1';
-const els = {
- remaining: document.querySelector('#remaining'),
- floor: document.querySelector('#floor'),
- door: document.querySelector('#door'),
- direction: document.querySelector('#direction'),
- passengers: document.querySelector('#passengers'),
- power: document.querySelector('#power'),
- powerText: document.querySelector('#powerText'),
- stability: document.querySelector('#stability'),
- stabilityText: document.querySelector('#stabilityText'),
- anomalyLevel: document.querySelector('#anomalyLevel'),
- reviveCount: document.querySelector('#reviveCount'),
- adHintsCount: document.querySelector('#adHintsCount'),
- hiddenLogsCount: document.querySelector('#hiddenLogsCount'),
- fakeEndingOverlay: document.querySelector('#fakeEndingOverlay'),
- fakeEndingEyebrow: document.querySelector('#fakeEndingEyebrow'),
- fakeEndingTitle: document.querySelector('#fakeEndingTitle'),
- fakeEndingText: document.querySelector('#fakeEndingText'),
- fakeEndingTruth: document.querySelector('#fakeEndingTruth'),
- fakeEndingTruthBtn: document.querySelector('#fakeEndingTruthBtn'),
- fakeEndingRestartBtn: document.querySelector('#fakeEndingRestartBtn'),
- monitor: document.querySelector('#monitor'),
- monitorCaption: document.querySelector('#monitorCaption'),
- monitorFloor: document.querySelector('#monitorFloor'),
- monitorSignal: document.querySelector('#monitorSignal'),
- operatorCue: document.querySelector('#operatorCue'),
- monitorThreat: document.querySelector('#monitorThreat'),
- actions: document.querySelector('#actions'),
- moreActions: document.querySelector('#moreActions'),
- secondaryActionCount: document.querySelector('#secondaryActionCount'),
- secondaryActionsSheet: document.querySelector('#secondaryActionsSheet'),
- secondaryActionsBackdrop: document.querySelector('#secondaryActionsBackdrop'),
- closeSecondaryActions: document.querySelector('#closeSecondaryActions'),
- secondaryActions: document.querySelector('#secondaryActions'),
- logs: document.querySelector('#logs'),
- forceAnomaly: document.querySelector('#forceAnomaly'),
- startOverlay: document.querySelector('#startOverlay'),
- startTitle: document.querySelector('#startTitle'),
- startCopy: document.querySelector('#startCopy'),
- startChecklist: document.querySelector('#startChecklist'),
- startFailureRules: document.querySelector('#startFailureRules'),
- startButton: document.querySelector('#startButton'),
- openArchiveBtn: document.querySelector('#openArchiveBtn'),
- archiveOverlay: document.querySelector('#archiveOverlay'),
- archiveStats: document.querySelector('#archiveStats'),
- archiveAnomalyList: document.querySelector('#archiveAnomalyList'),
- closeArchiveBtn: document.querySelector('#closeArchiveBtn'),
- overlay: document.querySelector('#failureOverlay'),
- failureReason: document.querySelector('#failureReason'),
- failureMetrics: document.querySelector('#failureMetrics'),
- postRunSummary: document.querySelector('#postRunSummary'),
- adHint: document.querySelector('#adHint'),
- reviveButton: document.querySelector('#reviveButton'),
- restartButton: document.querySelector('#restartButton'),
- remainingLabel: document.querySelector('#remainingLabel'),
- statusPanelTitle: document.querySelector('#statusPanelTitle'),
- monitorPanelTitle: document.querySelector('#monitorPanelTitle'),
- actionPanelTitle: document.querySelector('#actionPanelTitle'),
- logPanelTitle: document.querySelector('#logPanelTitle'),
- failureTitle: document.querySelector('#failureTitle'),
- floorLabel: document.querySelector('#floorLabel'),
- doorLabel: document.querySelector('#doorLabel'),
- directionLabel: document.querySelector('#directionLabel'),
- passengersLabel: document.querySelector('#passengersLabel'),
- powerLabel: document.querySelector('#powerLabel'),
- stabilityLabel: document.querySelector('#stabilityLabel'),
- anomalyLevelLabel: document.querySelector('#anomalyLevelLabel'),
- reviveCountLabel: document.querySelector('#reviveCountLabel'),
- adHintsCountLabel: document.querySelector('#adHintsCountLabel'),
- hiddenLogsCountLabel: document.querySelector('#hiddenLogsCountLabel'),
-};
-
-let session = createRuntimeSession();
-let state = session.state;
-let nextAnomalyAt = session.nextAnomalyAt;
-let timer = null;
-let lastTone = 'normal';
-let crashPlayed = false;
-let fakeEndingTracked = false;
-let runToken = 0;
-
-function analyticsPayload(extra = {}) {
- return {
- skinId: getSkin().meta?.id,
- elapsed: state.elapsed,
- remaining: state.remaining,
- anomalyLevel: state.anomalyLevel,
- ...extra,
- };
-}
-
-function ensureTimer() {
- if (timer) return;
- if (els.startOverlay) els.startOverlay.hidden = true;
- timer = window.setInterval(loop, 1000);
- trackEvent('game_start', analyticsPayload());
-}
-
-function bindPress(element, handler) {
- if (!element) return;
- let handledAt = 0;
- const run = (event) => {
- event?.preventDefault?.();
- const now = Date.now();
- if (now - handledAt < 350) return;
- handledAt = now;
- handler(event);
- };
- element.addEventListener('click', run);
- element.addEventListener('touchend', run, { passive: false });
- element.addEventListener('pointerup', run);
-}
-
-const showReviveAd = createRewardedAd(CONFIG.adUnits.revive, {
- onReward: (meta) => {
- if (!shouldApplyReward(meta, runToken, 'revive', state)) return;
- trackEvent('revive_ad_reward', analyticsPayload({ adUnitId: CONFIG.adUnits.revive }));
- playRevive();
- state = reviveFromAd(state);
- nextAnomalyAt = scheduleNextAnomalyAfterRevive(state.elapsed);
- render();
- },
-});
-const showDecodeAd = createRewardedAd(CONFIG.adUnits.decode, {
- onReward: (meta) => {
- if (!shouldApplyReward(meta, runToken, 'decode', state)) return;
- const before = state.adHintsUsed;
- runAction('unlockHiddenLog');
- if (state.adHintsUsed > before) {
- trackEvent('hidden_log_unlock', analyticsPayload({ adUnitId: CONFIG.adUnits.decode }));
- }
- },
-});
-const showTruthAd = createRewardedAd(CONFIG.adUnits.truth, {
- onReward: (meta) => {
- if (!shouldApplyReward(meta, runToken, 'truth', state)) return;
- playRevive();
- state = structuredClone(state);
- state.fakeEndingUnlocked = true;
- render();
- },
-});
-
-const ACTION_ICONS = {
- openDoor: '◀▯▶',
- closeDoor: '▶▯◀',
- moveUp: '▲',
- moveDown: '▼',
- emergencyStop: 'STOP',
- restartSystem: '↻',
- inspectLog: 'LOG',
- unlockHiddenLog: 'KEY',
-};
-
-const ACTION_SHORT_LABELS = {
- openDoor: '开门',
- closeDoor: '关门',
- moveUp: '上行',
- moveDown: '下行',
- emergencyStop: '急停',
- restartSystem: '重启',
- inspectLog: '日志',
- unlockHiddenLog: '解码',
-};
-
-const PRIMARY_ACTION_IDS = new Set(['closeDoor', 'moveUp', 'emergencyStop']);
-
-function isPrimaryAction(actionId) {
- return PRIMARY_ACTION_IDS.has(actionId);
-}
-
-function closeSecondaryActions() {
- if (!els.secondaryActionsSheet) return;
- els.secondaryActionsSheet.hidden = true;
- if (els.moreActions) els.moreActions.setAttribute('aria-expanded', 'false');
-}
-
-function openSecondaryActions() {
- if (!els.secondaryActionsSheet) return;
- els.secondaryActionsSheet.hidden = false;
- if (els.moreActions) els.moreActions.setAttribute('aria-expanded', 'true');
-}
-
-function createActionButton(action, lockedCount, visual) {
- const button = document.createElement('button');
- button.type = 'button';
- button.dataset.action = action.id;
- button.dataset.recommended = String(visual.highlightAction === action.id);
- const keycap = document.createElement('span');
- keycap.className = 'action-keycap';
- const icon = document.createElement('span');
- icon.className = 'action-icon';
- icon.setAttribute('aria-hidden', 'true');
- icon.textContent = ACTION_ICONS[action.id] || '●';
- const label = document.createElement('span');
- label.className = 'action-label';
- label.textContent = ACTION_SHORT_LABELS[action.id] || (action.id === 'unlockHiddenLog'
- ? actionLabel(action.id, lockedCount)
- : action.label);
- button.setAttribute('aria-label', action.id === 'unlockHiddenLog'
- ? actionLabel(action.id, lockedCount)
- : action.label);
- keycap.append(icon, label);
- button.append(keycap);
- bindPress(button, () => dispatchAction(action.id));
- return button;
-}
-
-function renderActions(visual = deriveVisualState(state)) {
- els.actions.replaceChildren();
- els.secondaryActions?.replaceChildren();
- const lockedCount = state.hiddenLogs.filter(h => h.locked).length;
- let secondaryCount = 0;
- let secondaryRecommended = false;
- for (const action of getAvailableActions()) {
- // 解码加密记录按钮只在有锁定日志时显示
- if (action.id === 'unlockHiddenLog' && lockedCount === 0) continue;
- const button = createActionButton(action, lockedCount, visual);
- if (isPrimaryAction(action.id) || !els.secondaryActions) {
- els.actions.append(button);
- } else {
- secondaryCount += 1;
- if (visual.highlightAction === action.id) secondaryRecommended = true;
- els.secondaryActions.append(button);
- }
- }
- if (els.secondaryActionCount) els.secondaryActionCount.textContent = String(secondaryCount);
- if (els.moreActions) {
- els.moreActions.hidden = secondaryCount === 0;
- els.moreActions.dataset.recommended = String(secondaryRecommended);
- if (secondaryCount === 0) closeSecondaryActions();
- }
-}
-
-function render() {
- const labels = getDomLabels();
- const visual = deriveVisualState(state);
- renderActions(visual);
- root.dataset.tone = visual.tone;
- els.remaining.textContent = Math.ceil(state.remaining);
- els.floor.textContent = state.floor;
- els.door.textContent = getDoorLabel(state.door);
- els.direction.textContent = getDirectionLabel(state.direction);
- els.passengers.textContent = state.passengers;
- els.power.value = state.power;
- els.powerText.textContent = Math.round(state.power);
- els.stability.value = state.stability;
- els.stabilityText.textContent = Math.round(state.stability);
- els.anomalyLevel.textContent = state.anomalyLevel;
- els.reviveCount.textContent = state.adRevivesUsed;
-
- // 隐藏日志统计
- const lockedCount = state.hiddenLogs.filter(h => h.locked).length;
- const unlockedCount = state.hiddenLogs.filter(h => !h.locked).length;
- if (els.hiddenLogsCount) els.hiddenLogsCount.textContent = lockedCount;
- if (els.adHintsCount) els.adHintsCount.textContent = state.adHintsUsed;
- const tone = visual.tone;
- if (els.monitorSignal) {
- const signal = tone === 'danger' || tone === 'critical'
- ? labels.monitorSignal.corrupted
- : state.anomalyLevel > 0 || tone === 'warn'
- ? labels.monitorSignal.unstable
- : labels.monitorSignal.stable;
- els.monitorSignal.textContent = signal;
- }
- if (els.monitorThreat) els.monitorThreat.textContent = labels.monitorThreat(state.anomalyLevel);
- if (els.operatorCue) {
- const recommendedLabel = visual.highlightAction ? (ACTION_SHORT_LABELS[visual.highlightAction] || actionLabel(visual.highlightAction)) : null;
- els.operatorCue.textContent = getOperatorCue(state, nextAnomalyAt, recommendedLabel);
- }
- // 显示已解锁的隐藏日志内容
- const unlockedHidden = state.hiddenLogs.filter(h => !h.locked);
- const monitorText = unlockedHidden.length > 0
- ? getDecodedMonitorText(unlockedHidden[unlockedHidden.length - 1])
- : state.monitor;
- if (els.monitorCaption) els.monitorCaption.textContent = monitorText;
- else els.monitor.textContent = monitorText;
- if (els.monitorFloor) els.monitorFloor.textContent = state.floor;
- if (els.monitor) {
- els.monitor.dataset.door = state.door;
- els.monitor.dataset.moving = String(state.moving);
- els.monitor.dataset.anomaly = visual.glitch ? 'active' : 'clear';
- els.monitor.dataset.glitch = String(visual.glitch);
- els.monitor.dataset.shake = String(visual.shake);
- els.monitor.dataset.cctvState = visual.cctvState;
- els.monitor.style.setProperty('--cctv-noise', String(visual.noise));
- els.monitor.dataset.passengers = state.passengers > 0 ? 'present' : 'missing';
- }
-
- els.logs.replaceChildren();
- for (const line of state.logs.slice(-CONFIG.logs.displayLines)) {
- const li = document.createElement('li');
- li.className = [line.type, line.priority ? `log-priority-${line.priority}` : 'log-priority-normal'].join(' ');
- li.textContent = line.text;
- els.logs.append(li);
- }
- els.logs.scrollTop = els.logs.scrollHeight;
-
- if (state.gameOver) {
- const isSuccess = state.result === 'success';
- if (!isSuccess && state.fakeEndingTriggered) {
- // 假结局
- els.overlay.hidden = true;
- els.fakeEndingOverlay.hidden = false;
- const threshold = CONFIG.fakeEnding.consecutiveFailuresThreshold;
- els.fakeEndingText.textContent = t('fakeEnding.text', {
- count: state.fakeEndingCount || CONFIG.fakeEnding.consecutiveFailuresThreshold,
- threshold: threshold,
- });
- if (state.fakeEndingUnlocked) {
- els.fakeEndingTruth.textContent = t('fakeEnding.truthContent');
- els.fakeEndingTruthBtn.hidden = true;
- } else {
- els.fakeEndingTruth.textContent = t('fakeEnding.truthPlaceholder');
- els.fakeEndingTruthBtn.hidden = false;
- }
- } else {
- // 正常失败
- els.fakeEndingOverlay.hidden = true;
- els.overlay.hidden = false;
- els.overlay.dataset.result = isSuccess ? 'success' : 'failure';
- els.failureTitle.textContent = isSuccess ? t('ui.shiftComplete') : labels.failureTitle;
- els.failureReason.textContent = isSuccess ? t('ui.successfulShift') : summarizeFailure(state);
- els.reviveButton.hidden = isSuccess;
- els.adHint.hidden = isSuccess;
- if (els.failureMetrics) {
- const metrics = labels.failureMetrics.map(({ key, label }) => {
- const value = key === 'remaining' ? Math.ceil(state.remaining) : Math.round(state[key]);
- return [label, value];
- });
- els.failureMetrics.replaceChildren(...metrics.map(([label, value]) => {
- const item = document.createElement('span');
- const labelEl = document.createElement('b');
- const valueEl = document.createElement('strong');
- labelEl.textContent = label;
- valueEl.textContent = value;
- item.append(labelEl, valueEl);
- return item;
- }));
- }
- els.adHint.textContent = state.lastAdHint
- ? t('failure.adHintPrefix', { hint: state.lastAdHint })
- : t('failure.defaultHint');
- // 局后复盘
- if (els.postRunSummary) {
- const unlockedLogs = state.hiddenLogs.filter(h => !h.locked).length;
- const totalAnomalies = state.anomaliesTriggeredTotal || 0;
- const peakSeverity = state.maxAnomalySeverity || 0;
- const severityLabel = peakSeverity >= 4 ? '致命' : peakSeverity >= 2 ? '高' : peakSeverity > 0 ? '低' : '无';
- const items = [
- ['存活秒数', state.elapsed],
- ['触发异常', totalAnomalies],
- ['最高威胁', `${peakSeverity}(${severityLabel})`],
- ['解锁日志', unlockedLogs],
- ['复活次数', state.adRevivesUsed || 0],
- ];
- if (state.fakeEndingTriggered) items.push(['假结局', '已触发']);
- els.postRunSummary.replaceChildren(...items.map(([label, value]) => {
- const item = document.createElement('span');
- const labelEl = document.createElement('b');
- const valueEl = document.createElement('strong');
- labelEl.textContent = label;
- valueEl.textContent = value;
- item.append(labelEl, valueEl);
- return item;
- }));
- }
- }
- } else {
- els.overlay.hidden = true;
- els.fakeEndingOverlay.hidden = true;
- }
-}
-
-function dispatchAction(actionId) {
- ensureTimer();
- playClick();
- closeSecondaryActions();
- trackEvent('action_click', analyticsPayload({ actionId }));
- if (actionId === 'unlockHiddenLog') {
- trackEvent('hidden_log_ad_start', analyticsPayload({ adUnitId: CONFIG.adUnits.decode }));
- showDecodeAd({ runToken });
- return;
- }
- runAction(actionId);
-}
-
-function runAction(actionId) {
- const result = performAction(state, actionId);
- state = result.state;
- if (result.ok) {
- playSuccess();
- } else {
- playFail();
- }
- render();
-}
-
-function triggerAnomaly() {
- if (state.gameOver) return;
- ensureTimer();
- const picked = pickNextAnomaly(state);
- const result = applyAnomaly(state, picked.id);
- state = result.state;
- trackEvent('anomaly_trigger', analyticsPayload({
- anomalyId: result.event.id,
- severity: result.event.severity,
- }));
- playAnomaly();
- nextAnomalyAt = scheduleNextAnomalyAfterTrigger(state.elapsed);
- render();
-}
-
-function loop() {
- if (state.gameOver) {
- if (!crashPlayed) {
- const isSuccess = state.result === 'success';
- if (isSuccess) playSuccess();
- else playCrash();
- crashPlayed = true;
- trackEvent('game_over', analyticsPayload({
- result: state.result,
- reason: isSuccess ? 'shift_complete' : summarizeFailure(state),
- anomaliesTriggeredTotal: state.anomaliesTriggeredTotal || 0,
- maxAnomalySeverity: state.maxAnomalySeverity || 0,
- }));
- // 提交本局数据到跨局档案库
- try {
- const ids = state.hiddenLogs?.map(h => h.id?.replace(/_log$/, '')).filter(Boolean) || [];
- const unlockedIds = state.hiddenLogs?.filter(h => !h.locked).map(h => h.id) || [];
- commitSessionToArchive({
- skinId: getSkin().meta?.id,
- anomaliesTriggeredTotal: state.anomaliesTriggeredTotal || 0,
- maxAnomalySeverity: state.maxAnomalySeverity || 0,
- anomalyIds: ids,
- unlockedLogIds: unlockedIds,
- });
- refreshArchiveButton();
- } catch { /* localStorage unavailable — skip */ }
- }
- render();
- return;
- }
- crashPlayed = false;
- fakeEndingTracked = false;
- state = tickState(state, 1);
-
- // 成功值守 → 重置连续失败计数
- if (state.gameOver && state.result === 'success') {
- state = recordSuccessfulShift(state);
- render();
- return;
- }
-
- // 检测失败 → 递增连续失败计数
- if (state.gameOver) {
- state = recordFailure(state);
- if (state.fakeEndingTriggered && !fakeEndingTracked) {
- fakeEndingTracked = true;
- trackEvent('fake_ending_trigger', analyticsPayload({
- fakeEndingCount: state.fakeEndingCount,
- }));
- }
- }
- // Save a snapshot on interval for ad-revive rollback
- const ar = CONFIG.adRevive;
- if (state.elapsed > 0 && state.elapsed % ar.snapshotInterval === 0) {
- state = saveSnapshot(state);
- }
- if (!state.gameOver && state.elapsed >= nextAnomalyAt) triggerAnomaly();
- // Play warning sound on tone transitions to critical/danger
- const currentTone = deriveVisualState(state).tone;
- if (currentTone === 'danger' || currentTone === 'critical') {
- if (lastTone !== currentTone) playWarning();
- }
- lastTone = currentTone;
- render();
-}
-
-function restart() {
- runToken += 1;
- if (timer) {
- window.clearInterval(timer);
- timer = null;
- }
- if (els.startOverlay) els.startOverlay.hidden = false;
- session = restartRuntimeSession({ state });
- state = session.state;
- nextAnomalyAt = session.nextAnomalyAt;
- fakeEndingTracked = false;
- render();
- refreshArchiveButton();
-}
-
-function refreshArchiveButton() {
- if (!els.openArchiveBtn) return;
- const archive = loadArchive();
- els.openArchiveBtn.hidden = archive.sessionsPlayed === 0;
-}
-
-function renderArchive() {
- const archive = loadArchive();
- const skinProgress = getArchiveSkinProgress(archive, getSkin().meta?.id, getAnomalies());
- if (els.archiveStats) {
- const ids = Object.keys(archive.encounteredAnomalies).length;
- const logs = Object.keys(archive.unlockedLogs).length;
- const items = [
- ['总场次', archive.sessionsPlayed],
- ['遭遇异常', ids],
- ['解锁日志', logs],
- ['总异常数', archive.totalAnomaliesTriggered],
- ['最高威胁', archive.highestSeverity],
- ['皮肤进度', `${skinProgress.encounteredCount}/${skinProgress.totalAnomalies}`],
- ['日志解锁', `${skinProgress.unlockedLogsCount}/${skinProgress.totalAnomalies}`],
- ];
- els.archiveStats.replaceChildren(...items.map(([label, value]) => {
- const item = document.createElement('span');
- const labelEl = document.createElement('b');
- const valueEl = document.createElement('strong');
- labelEl.textContent = label;
- valueEl.textContent = value;
- item.append(labelEl, valueEl);
- return item;
- }));
- }
- if (els.archiveAnomalyList) {
- const anomalies = getAnomalies();
- els.archiveAnomalyList.replaceChildren(...Object.entries(archive.encounteredAnomalies)
- .sort((a, b) => b[1] - a[1])
- .map(([id, count]) => {
- const def = anomalies.find(a => a.id === id);
- const item = document.createElement('div');
- item.className = 'anomaly-entry';
- const name = document.createElement('span');
- name.textContent = def?.title || id;
- const badge = document.createElement('strong');
- badge.textContent = `×${count}`;
- item.append(name, badge);
- return item;
- }));
- }
-}
-
-function applyDomLabels() {
- const labels = getDomLabels();
- els.remainingLabel.textContent = labels.countdown;
- els.statusPanelTitle.textContent = labels.statusPanel;
- els.monitorPanelTitle.textContent = labels.monitorPanel;
- els.actionPanelTitle.textContent = labels.actionPanel;
- els.logPanelTitle.textContent = labels.logPanel;
- els.failureTitle.textContent = labels.failureTitle;
- els.forceAnomaly.textContent = 'ANOM';
- els.forceAnomaly.setAttribute('aria-label', labels.forceAnomaly);
- els.forceAnomaly.hidden = !debugMode;
- els.reviveButton.textContent = labels.revive;
- els.restartButton.textContent = labels.restart;
- els.fakeEndingTruthBtn.textContent = labels.revealTruth;
- els.fakeEndingRestartBtn.textContent = labels.restart;
- if (els.fakeEndingEyebrow) els.fakeEndingEyebrow.textContent = t('fakeEnding.eyebrow');
- if (els.fakeEndingTitle) els.fakeEndingTitle.textContent = t('fakeEnding.title');
- if (els.startTitle) els.startTitle.textContent = '接管电梯';
- if (els.startCopy) els.startCopy.textContent = '看监控,按键救场。';
- if (els.startButton) els.startButton.textContent = 'OVERRIDE';
- if (els.startChecklist) {
- const compactMissions = ['60s', 'CCTV', 'CONTROL'];
- els.startChecklist.replaceChildren(...compactMissions.map((item) => {
- const chip = document.createElement('span');
- chip.textContent = item;
- return chip;
- }));
- }
- if (els.startFailureRules) {
- const compactRisks = ['POWER', 'STABILITY', 'ANOMALY'];
- els.startFailureRules.replaceChildren(...compactRisks.map((item) => {
- const chip = document.createElement('span');
- chip.textContent = item;
- return chip;
- }));
- }
- els.floorLabel.textContent = labels.status.floor;
- els.doorLabel.textContent = labels.status.door;
- els.directionLabel.textContent = labels.status.direction;
- els.passengersLabel.textContent = labels.status.passengers;
- els.powerLabel.textContent = labels.status.power;
- els.stabilityLabel.textContent = labels.status.stability;
- els.anomalyLevelLabel.textContent = labels.status.anomalyLevel;
- els.reviveCountLabel.textContent = labels.status.reviveCount;
- els.adHintsCountLabel.textContent = labels.status.adHintsCount;
- els.hiddenLogsCountLabel.textContent = labels.status.hiddenLogsCount;
-}
-
-applyDomLabels();
-refreshArchiveButton();
-bindPress(els.startButton, () => {
- playClick();
- ensureTimer();
-});
-bindPress(els.forceAnomaly, triggerAnomaly);
-bindPress(els.moreActions, openSecondaryActions);
-bindPress(els.closeSecondaryActions, closeSecondaryActions);
-bindPress(els.secondaryActionsBackdrop, closeSecondaryActions);
-window.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') closeSecondaryActions();
-});
-bindPress(els.reviveButton, () => {
- trackEvent('revive_ad_start', analyticsPayload({ adUnitId: CONFIG.adUnits.revive }));
- showReviveAd({ runToken });
-});
-bindPress(els.restartButton, () => {
- playRestart();
- restart();
-});
-
-// 假结局按钮
-bindPress(els.fakeEndingTruthBtn, () => {
- showTruthAd({ runToken });
-});
-bindPress(els.fakeEndingRestartBtn, () => {
- playRestart();
- restart();
-});
-
-// 档案库
-bindPress(els.openArchiveBtn, () => {
- renderArchive();
- els.archiveOverlay.hidden = false;
-});
-bindPress(els.closeArchiveBtn, () => {
- els.archiveOverlay.hidden = true;
-});
-
-// 从皮肤设置标题和副标题
-const meta = getSkin().meta;
-if (meta) {
- const titleEl = document.querySelector('#gameTitle');
- const subEl = document.querySelector('#gameSubtitle');
- if (titleEl) titleEl.textContent = meta.name;
- if (subEl) subEl.textContent = meta.subtitle;
- root.dataset.skin = meta.id;
-}
-
-render();
-window.addEventListener('beforeunload', () => window.clearInterval(timer));
-
-
-
-// ── 启动 ──
-console.log('[MINIGAME] Running on', 'android');
-})();
diff --git a/android-webview/app/src/main/assets/index.html b/android-webview/app/src/main/assets/index.html
deleted file mode 100644
index 1001eaa..0000000
--- a/android-webview/app/src/main/assets/index.html
+++ /dev/null
@@ -1,182 +0,0 @@
-
-
-
-
-
- 异常电梯控制台 | MINIGAME
-
-
-
-
-
-
-
-
- 电梯状态
-
- F
- 楼层
- 1
- ▯
- 门状态
- 关闭
- ↕
- 方向
- 待机
- ●
- 乘客
- 1
- ⚡
- 电源
- 100
- ◇
- 稳定度
- 100
- !
- 异常等级
- 0
- ↺
- 广告复活
- 0
- ◇
- 加密解码
- 0
- ▤
- 待解码
- 0
-
-
-
-
- 监控画面
-
-
-
CAM-03 · ELEVATOR SHAFT
-
23:59:47 · REC
-
-
-
-
-
-
-
-
CAM-01
-
CAM-07
-
THERM
-
-
-
监控画面稳定:1 层轿厢内有 1 名乘客。
-
-
- SIGNAL: STABLE
- STANDBY: 首个异常 8s 内出现;盯住 CCTV。
- THREAT: 0
-
-
-
-
-
- 操作面板
-
-
-
-
-
-
- 先看 CCTV
- 画面异常优先于楼层读数。
-
-
- 按推荐键
- 黄色高亮是当前建议动作。
-
-
-
-
-
-
-
-
-
-
-
OPERATOR HANDOFF REQUIRED
-
等待接管异常电梯
-
60 秒守住轿厢。看监控,按键处置。
-
- 60sCCTVNO FAIL
-
-
- POWERSTABILITYANOMALY
-
-
-
-
-
-
-
-
-
SYSTEM FAILURE
-
系统崩溃
-
异常等级失控。可观看广告复活。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
SECONDARY CONTROLS
-
低频操作
-
-
-
-
-
-
-
-
-
-
-
ANOMALY ARCHIVE
-
异常档案库
-
-
-
-
-
-
-
-
-
-
⚠ SYSTEM ANOMALY DETECTED
-
操作员关联异常
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android-webview/app/src/main/assets/styles.css b/android-webview/app/src/main/assets/styles.css
deleted file mode 100644
index 393cdf2..0000000
--- a/android-webview/app/src/main/assets/styles.css
+++ /dev/null
@@ -1,3245 +0,0 @@
-:root {
- color-scheme: dark;
- --bg: #020405;
- --panel: rgba(3, 18, 21, 0.86);
- --panel-2: rgba(6, 29, 32, 0.92);
- --panel-3: rgba(4, 11, 14, 0.96);
- --line: rgba(97, 255, 190, 0.24);
- --line-hot: rgba(255, 209, 102, 0.42);
- --text: #d8fff3;
- --muted: #77a69b;
- --green: #61ffbe;
- --amber: #ffd166;
- --red: #ff4d6d;
- --cyan: #51d6ff;
- --shadow: rgba(0, 0, 0, 0.58);
- --cctv-feed: url("assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png");
- --cctv-feed-anomaly: url("assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png");
- --cctv-feed-danger: url("assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png");
- --cctv-state-00-idle-closed: url("assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png");
- --cctv-state-01-door-open: url("assets/abnormal_elevator_visual_assets/cctv_states/01_door_open.png");
- --cctv-state-02-door-opening: url("assets/abnormal_elevator_visual_assets/cctv_states/02_door_opening.png");
- --cctv-state-03-door-closing: url("assets/abnormal_elevator_visual_assets/cctv_states/03_door_closing.png");
- --cctv-state-04-moving-up: url("assets/abnormal_elevator_visual_assets/cctv_states/04_moving_up.png");
- --cctv-state-05-moving-down: url("assets/abnormal_elevator_visual_assets/cctv_states/05_moving_down.png");
- --cctv-state-06-power-low: url("assets/abnormal_elevator_visual_assets/cctv_states/06_power_low.png");
- --cctv-state-07-power-outage: url("assets/abnormal_elevator_visual_assets/cctv_states/07_power_outage.png");
- --cctv-state-08-emergency-stop: url("assets/abnormal_elevator_visual_assets/cctv_states/08_emergency_stop.png");
- --cctv-state-09-door-jammed: url("assets/abnormal_elevator_visual_assets/cctv_states/09_door_jammed.png");
- --cctv-state-10-signal-lost: url("assets/abnormal_elevator_visual_assets/cctv_states/10_signal_lost.png");
- --cctv-state-11-camera-glitch: url("assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png");
- --cctv-state-12-scan-active: url("assets/abnormal_elevator_visual_assets/cctv_states/12_scan_active.png");
- --cctv-state-13-entity-near: url("assets/abnormal_elevator_visual_assets/cctv_states/13_entity_near.png");
- --cctv-state-14-shadow-inside: url("assets/abnormal_elevator_visual_assets/cctv_states/14_shadow_inside.png");
- --cctv-state-15-anomaly-wandering: url("assets/abnormal_elevator_visual_assets/cctv_states/15_anomaly_wandering.png");
- --cctv-state-16-wrong-floor: url("assets/abnormal_elevator_visual_assets/cctv_states/16_wrong_floor.png");
- --cctv-state-17-loop-corridor: url("assets/abnormal_elevator_visual_assets/cctv_states/17_loop_corridor.png");
- --cctv-state-18-locked: url("assets/abnormal_elevator_visual_assets/cctv_states/18_locked.png");
- --cctv-state-19-stabilized: url("assets/abnormal_elevator_visual_assets/cctv_states/19_stabilized.png");
- --cctv-state-20-threat-high: url("assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png");
- --cctv-state-21-maintenance-mode: url("assets/abnormal_elevator_visual_assets/cctv_states/21_maintenance_mode.png");
- --cctv-state-22-system-reboot: url("assets/abnormal_elevator_visual_assets/cctv_states/22_system_reboot.png");
- --cctv-state-23-cooldown-safe: url("assets/abnormal_elevator_visual_assets/cctv_states/23_cooldown_safe.png");
- --legacy-cctv-feed: url("assets/generated/cctv-elevator-corridor-clear.png");
- --legacy-cctv-feed-anomaly: url("assets/generated/cctv-elevator-corridor-warp.png");
- --legacy-cctv-feed-danger: url("assets/generated/cctv-elevator-corridor-figure.png");
- --hud-glass-texture: url("assets/generated/texture-hud-glass.png");
- --control-panel-texture: url("assets/generated/texture-control-panel.png");
- --cctv-noise-texture: url("assets/generated/overlay-cctv-noise.png");
- --signal-tear-texture: url("assets/generated/overlay-signal-tear.png");
- --visual-kit-cctv-frame: url("assets/abnormal_elevator_visual_assets/overlays/overlay_cctv_frame.png");
- --visual-kit-glitch-blocks: url("assets/abnormal_elevator_visual_assets/overlays/overlay_glitch_blocks.png");
- --visual-kit-red-alert-frame: url("assets/abnormal_elevator_visual_assets/overlays/overlay_red_alert_frame.png");
- --visual-kit-scanlines: url("assets/abnormal_elevator_visual_assets/overlays/overlay_scanlines.png");
- --visual-kit-scan-sweep: url("assets/abnormal_elevator_visual_assets/overlays/overlay_scan_sweep.png");
- --visual-kit-vignette: url("assets/abnormal_elevator_visual_assets/overlays/overlay_vignette.png");
- --btn-close-default: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_close_default.png");
- --btn-disabled: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_disabled.png");
- --btn-log-secondary: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_log_secondary.png");
- --btn-more-secondary: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_more_secondary.png");
- --btn-pressed: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_pressed.png");
- --btn-scan-default: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_scan_default.png");
- --btn-stop-danger: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_stop_danger.png");
- --btn-up-recommended: url("assets/abnormal_elevator_visual_assets/button_sprites/btn_up_recommended.png");
- --cctv-noise: 0.18;
-}
-
-* { box-sizing: border-box; }
-
-[hidden] { display: none !important; }
-
-html {
- min-height: 100%;
- background: var(--bg);
-}
-
-body {
- margin: 0;
- min-height: 100vh;
- min-height: 100dvh;
- overflow-x: hidden;
- font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
- color: var(--text);
- background:
- linear-gradient(rgba(97,255,190,0.035) 1px, transparent 1px),
- linear-gradient(90deg, rgba(97,255,190,0.025) 1px, transparent 1px),
- radial-gradient(circle at 18% 8%, rgba(81, 214, 255, 0.14), transparent 30rem),
- radial-gradient(circle at 82% 12%, rgba(255, 77, 109, 0.18), transparent 28rem),
- radial-gradient(circle at 50% 110%, rgba(97, 255, 190, 0.12), transparent 34rem),
- #020405;
- background-size: 32px 32px, 32px 32px, auto, auto, auto, auto;
-}
-
-body::before {
- content: "";
- position: fixed;
- inset: 0;
- pointer-events: none;
- z-index: 10;
- background:
- repeating-linear-gradient(0deg, rgba(255,255,255,0.035) 0 1px, transparent 1px 4px),
- radial-gradient(circle at 50% 50%, transparent 0 58%, rgba(0,0,0,0.48) 100%);
- mix-blend-mode: screen;
- opacity: 0.28;
-}
-
-.console-shell {
- width: min(1320px, calc(100vw - 28px));
- min-height: 100vh;
- min-height: 100dvh;
- margin: 0 auto;
- padding: 24px 0 34px;
-}
-
-.console-shell[data-tone="warn"] .panel,
-.console-shell[data-tone="critical"] .panel { border-color: rgba(255, 209, 102, 0.48); }
-.console-shell[data-tone="danger"] .panel,
-.console-shell[data-tone="critical"] .monitor { border-color: rgba(255, 77, 109, 0.68); }
-
-.console-shell[data-skin="elevator"] {
- --cctv-feed: url("assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png");
- --cctv-feed-anomaly: url("assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png");
- --cctv-feed-danger: url("assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png");
-}
-.console-shell[data-skin="hospital"] { --cctv-feed: url("assets/generated/cctv-hospital-ward-real.png"); }
-.console-shell[data-skin="security"] { --cctv-feed: url("assets/generated/cctv-security-room-real.png"); }
-.console-shell[data-skin="factory"] { --cctv-feed: url("assets/generated/cctv-factory-real.png"); }
-.console-shell[data-skin="subway"] { --cctv-feed: url("assets/generated/cctv-subway-platform-real.png"); }
-.console-shell[data-skin="hotel"] { --cctv-feed: url("assets/generated/cctv-hotel-lobby-real.png"); }
-
-.topbar {
- position: relative;
- display: grid;
- grid-template-columns: minmax(0, 1fr) 170px;
- align-items: end;
- gap: 18px;
- margin-bottom: 16px;
- padding: 14px 16px 12px;
- border: 1px solid rgba(97,255,190,0.14);
- border-radius: 26px;
- background: linear-gradient(90deg, rgba(2,12,14,0.82), rgba(8,18,22,0.38));
- box-shadow: 0 24px 80px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.06);
- overflow: hidden;
-}
-
-.topbar::after {
- content: "CTRL-LINK // CCTV // EMERGENCY BUS ACTIVE";
- position: absolute;
- right: 204px;
- top: 14px;
- color: rgba(97,255,190,0.28);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.7rem;
- letter-spacing: 0.2em;
-}
-
-.eyebrow {
- margin: 0 0 8px;
- color: var(--green);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.76rem;
- font-weight: 900;
- letter-spacing: 0.26em;
- text-transform: uppercase;
-}
-
-h1, h2 { margin: 0; }
-h1 {
- max-width: 820px;
- font-size: clamp(2.4rem, 6vw, 5.4rem);
- line-height: 0.92;
- letter-spacing: -0.08em;
- color: #eafff8;
- text-shadow:
- 2px 0 rgba(255,77,109,0.42),
- -2px 0 rgba(81,214,255,0.36),
- 0 0 28px rgba(97,255,190,0.3);
-}
-
-.shift-card {
- min-width: 160px;
- padding: 14px 16px;
- border: 1px solid rgba(97,255,190,0.28);
- border-radius: 20px;
- background:
- radial-gradient(circle at 50% 0%, rgba(255,209,102,0.12), transparent 65%),
- rgba(0, 12, 15, 0.88);
- text-align: center;
- box-shadow: inset 0 0 22px rgba(97,255,190,0.06);
-}
-.shift-card span {
- display: block;
- color: var(--muted);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.76rem;
- letter-spacing: 0.14em;
-}
-.shift-card strong {
- display: block;
- margin-top: 2px;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 3rem;
- line-height: 1;
- color: var(--amber);
- text-shadow: 0 0 24px rgba(255,209,102,0.34);
-}
-
-.grid {
- display: grid;
- grid-template-columns: 0.82fr 1.18fr;
- grid-template-areas:
- "status monitor"
- "actions logs";
- gap: 14px;
-}
-
-.panel {
- position: relative;
- min-height: 260px;
- padding: 18px;
- border: 1px solid var(--line);
- border-radius: 24px;
- background:
- linear-gradient(rgba(3, 18, 21, 0.80), rgba(2, 10, 12, 0.91)),
- var(--hud-glass-texture) center / cover no-repeat,
- linear-gradient(180deg, rgba(255,255,255,0.035), transparent 28%),
- linear-gradient(180deg, var(--panel-2), var(--panel));
- box-shadow: 0 26px 90px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.06);
- overflow: hidden;
-}
-
-.panel::before {
- content: "";
- position: absolute;
- inset: 10px;
- pointer-events: none;
- border: 1px solid rgba(97,255,190,0.055);
- border-radius: 18px;
-}
-
-.panel-title {
- position: relative;
- z-index: 1;
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 14px;
- color: var(--green);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-weight: 900;
- letter-spacing: 0.14em;
-}
-.panel-title::before {
- content: "";
- width: 8px;
- height: 8px;
- border-radius: 999px;
- background: var(--green);
- box-shadow: 0 0 16px var(--green);
-}
-.status-panel { grid-area: status; }
-.monitor-panel { grid-area: monitor; }
-.action-panel {
- grid-area: actions;
- background:
- linear-gradient(rgba(2, 12, 13, 0.70), rgba(1, 6, 7, 0.88)),
- var(--control-panel-texture) center / cover no-repeat,
- linear-gradient(180deg, rgba(255,255,255,0.035), transparent 28%),
- linear-gradient(180deg, var(--panel-2), var(--panel));
-}
-.log-panel { grid-area: logs; }
-
-.status-list {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin: 0;
-}
-.status-list div {
- position: relative;
- min-height: 78px;
- padding: 12px 12px 10px;
- border: 1px solid rgba(97,255,190,0.13);
- border-radius: 16px;
- background:
- linear-gradient(135deg, rgba(97,255,190,0.07), transparent 52%),
- rgba(0, 8, 10, 0.58);
- overflow: hidden;
-}
-.status-list div::after {
- content: "";
- position: absolute;
- right: -18px;
- bottom: -18px;
- width: 58px;
- height: 58px;
- border: 1px solid rgba(81,214,255,0.12);
- border-radius: 999px;
-}
-dt {
- color: var(--muted);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.72rem;
- letter-spacing: 0.08em;
-}
-dd {
- margin: 6px 0 0;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 1.62rem;
- font-weight: 900;
- color: #cafff2;
- text-shadow: 0 0 12px rgba(81,214,255,0.24);
-}
-meter {
- width: 70%;
- height: 10px;
- margin-right: 8px;
- vertical-align: middle;
- filter: drop-shadow(0 0 8px rgba(97,255,190,0.2));
-}
-
-.monitor-panel { min-height: 360px; }
-.monitor {
- position: relative;
- z-index: 1;
- min-height: 250px;
- height: calc(100% - 44px);
- display: grid;
- grid-template-rows: minmax(0, 1fr) auto;
- gap: 12px;
- padding: 18px;
- border: 1px solid rgba(97, 255, 190, 0.2);
- border-radius: 20px;
- background:
- linear-gradient(rgba(97,255,190,0.06) 1px, transparent 1px),
- linear-gradient(90deg, rgba(97,255,190,0.045) 1px, transparent 1px),
- radial-gradient(circle at 50% 50%, rgba(97,255,190,0.14), rgba(0,0,0,0.24) 58%),
- #031012;
- background-size: 100% 6px, 6px 100%, auto, auto;
- color: #bffff0;
- font-family: "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
- text-align: center;
- text-shadow: 0 0 18px rgba(97,255,190,0.34);
- overflow: hidden;
-}
-.monitor::before {
- content: "CAM-03 / SIGNAL DEGRADED";
- position: absolute;
- left: 16px;
- top: 12px;
- z-index: 3;
- color: rgba(255,209,102,0.72);
- font-size: 0.68rem;
- letter-spacing: 0.16em;
-}
-.monitor::after {
- content: "REC ●";
- position: absolute;
- right: 16px;
- top: 12px;
- z-index: 3;
- color: var(--red);
- font-size: 0.72rem;
- letter-spacing: 0.16em;
-}
-.cctv-stage {
- position: relative;
- min-height: 160px;
- border-radius: 18px;
- overflow: hidden;
- background:
- linear-gradient(rgba(1, 14, 14, 0.22), rgba(0, 4, 5, 0.72)),
- var(--cctv-feed) center / cover no-repeat,
- radial-gradient(circle at 50% 44%, rgba(97,255,190,0.18), transparent 34%),
- linear-gradient(180deg, rgba(5, 22, 24, 0.95), rgba(0, 5, 6, 0.98));
- border: 1px solid rgba(97,255,190,0.16);
- box-shadow: inset 0 0 34px rgba(0,0,0,0.72), inset 0 0 90px rgba(97,255,190,0.08);
-}
-.monitor[data-anomaly="active"] .cctv-stage {
- --cctv-feed: var(--cctv-feed-anomaly);
-}
-.console-shell[data-tone="critical"] .monitor[data-anomaly="active"] .cctv-stage,
-.console-shell[data-tone="danger"] .monitor[data-anomaly="active"] .cctv-stage {
- --cctv-feed: var(--cctv-feed-danger);
-}
-.cctv-stage::before,
-.cctv-stage::after {
- content: "";
- position: absolute;
- inset: 0;
- pointer-events: none;
-}
-.cctv-stage::before {
- background:
- linear-gradient(rgba(3, 20, 16, 0.08), rgba(0,0,0,0.16)),
- var(--cctv-noise-texture) center / cover no-repeat,
- repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 5px),
- radial-gradient(circle at 50% 50%, transparent 0 56%, rgba(0,0,0,0.54) 100%);
- mix-blend-mode: screen;
- opacity: 0.36;
-}
-.cctv-stage::after {
- background: linear-gradient(90deg, transparent, rgba(81,214,255,0.06), transparent);
- transform: translateX(-120%);
- animation: cctvSweep 5s linear infinite;
-}
-@keyframes cctvSweep { to { transform: translateX(120%); } }
-.monitor[data-anomaly="active"] .cctv-stage::after {
- background:
- linear-gradient(rgba(255, 77, 109, 0.10), rgba(0,0,0,0.18)),
- var(--signal-tear-texture) center / cover no-repeat,
- linear-gradient(90deg, transparent, rgba(255,77,109,0.14), transparent);
- mix-blend-mode: screen;
- opacity: 0.52;
- transform: none;
- animation: signalTear 820ms steps(2) infinite;
-}
-@keyframes signalTear { 50% { transform: translateX(2px) scaleY(1.01); filter: hue-rotate(-12deg); } }
-.camera-frame {
- position: absolute;
- inset: 16px;
- border: 1px solid rgba(97,255,190,0.18);
- border-radius: 14px;
- overflow: hidden;
- perspective: 420px;
-}
-.shaft-lines {
- position: absolute;
- inset: 0;
- background:
- linear-gradient(90deg, transparent 0 14%, rgba(97,255,190,0.12) 14% 15%, transparent 15% 49%, rgba(97,255,190,0.08) 49% 51%, transparent 51% 85%, rgba(97,255,190,0.12) 85% 86%, transparent 86%),
- repeating-linear-gradient(180deg, rgba(97,255,190,0.10) 0 1px, transparent 1px 28px);
- transform: rotateX(18deg) scale(1.08);
- opacity: 0.72;
-}
-.elevator-car {
- position: absolute;
- left: 50%;
- bottom: 8%;
- width: min(52%, 250px);
- height: 72%;
- transform: translateX(-50%);
- border: 2px solid rgba(191,255,240,0.42);
- border-radius: 10px 10px 6px 6px;
- background:
- linear-gradient(90deg, rgba(255,255,255,0.05), transparent 24% 76%, rgba(255,255,255,0.05)),
- rgba(0, 12, 14, 0.74);
- box-shadow: 0 0 42px rgba(97,255,190,0.18), inset 0 0 42px rgba(0,0,0,0.72);
- overflow: hidden;
-}
-.monitor[data-moving="true"] .elevator-car { animation: carJitter 360ms steps(2) infinite; }
-@keyframes carJitter { 50% { transform: translateX(calc(-50% + 2px)) translateY(-1px); } }
-.door {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 50%;
- background: linear-gradient(180deg, rgba(191,255,240,0.10), rgba(0,0,0,0.20));
- border-color: rgba(97,255,190,0.20);
- transition: transform 280ms ease;
-}
-.door-left { left: 0; border-right: 1px solid rgba(97,255,190,0.22); }
-.door-right { right: 0; border-left: 1px solid rgba(97,255,190,0.22); }
-.monitor[data-door="open"] .door-left { transform: translateX(-62%); }
-.monitor[data-door="open"] .door-right { transform: translateX(62%); }
-.passenger-heat {
- position: absolute;
- left: 50%;
- bottom: 18%;
- width: 38px;
- height: 76px;
- transform: translateX(-50%);
- border-radius: 999px 999px 30px 30px;
- background:
- radial-gradient(circle at 50% 17%, rgba(255,209,102,0.9) 0 12px, transparent 13px),
- radial-gradient(ellipse at 50% 68%, rgba(255,77,109,0.75), rgba(255,209,102,0.16) 62%, transparent 64%);
- filter: blur(0.2px) drop-shadow(0 0 16px rgba(255,209,102,0.55));
- opacity: 0.88;
-}
-.monitor[data-passengers="missing"] .passenger-heat {
- opacity: 0.18;
- filter: grayscale(1) blur(1px) drop-shadow(0 0 18px rgba(81,214,255,0.38));
-}
-.floor-indicator {
- position: absolute;
- left: 12px;
- bottom: 10px;
- padding: 5px 8px;
- border: 1px solid rgba(97,255,190,0.22);
- border-radius: 999px;
- color: rgba(216,255,243,0.9);
- background: rgba(0,0,0,0.44);
- font-size: 0.72rem;
- letter-spacing: 0.08em;
-}
-.anomaly-reticle {
- position: absolute;
- right: 12px;
- bottom: 12px;
- width: 52px;
- height: 52px;
- border: 1px solid rgba(97,255,190,0.18);
- border-radius: 50%;
- opacity: 0.35;
-}
-.anomaly-reticle::before,
-.anomaly-reticle::after {
- content: "";
- position: absolute;
- background: currentColor;
- color: rgba(97,255,190,0.72);
-}
-.anomaly-reticle::before { left: 50%; top: 6px; bottom: 6px; width: 1px; }
-.anomaly-reticle::after { top: 50%; left: 6px; right: 6px; height: 1px; }
-.monitor[data-anomaly="active"] .anomaly-reticle {
- opacity: 1;
- border-color: rgba(255,77,109,0.74);
- box-shadow: 0 0 24px rgba(255,77,109,0.38);
- animation: reticlePulse 900ms ease-in-out infinite;
-}
-.monitor[data-anomaly="active"] .anomaly-reticle::before,
-.monitor[data-anomaly="active"] .anomaly-reticle::after { color: rgba(255,77,109,0.9); }
-@keyframes reticlePulse { 50% { transform: scale(1.08); } }
-.monitor-caption {
- position: relative;
- z-index: 2;
- margin: 0;
- padding: 10px 12px 12px;
- max-height: 5.2em;
- overflow: auto;
- border: 1px solid rgba(97,255,190,0.13);
- border-radius: 14px;
- background: rgba(0, 7, 8, 0.66);
- color: #bffff0;
- font-size: clamp(0.92rem, 1.7vw, 1.08rem);
- line-height: 1.45;
-}
-.monitor-hud {
- position: absolute;
- z-index: 2;
- left: 34px;
- right: 34px;
- bottom: 28px;
- display: flex;
- justify-content: space-between;
- gap: 10px;
- pointer-events: none;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.72rem;
- letter-spacing: 0.14em;
-}
-.monitor-hud span {
- padding: 7px 10px;
- border: 1px solid rgba(97, 255, 190, 0.22);
- border-radius: 999px;
- color: rgba(216, 255, 243, 0.86);
- background: rgba(0, 10, 12, 0.72);
- box-shadow: 0 0 18px rgba(97,255,190,0.08), inset 0 1px 0 rgba(255,255,255,0.05);
-}
-.monitor-hud .operator-cue {
- flex: 1;
- min-width: 0;
- text-align: center;
- border-color: rgba(255, 209, 102, 0.36);
- color: #ffeab2;
- background: linear-gradient(90deg, rgba(255,209,102,0.10), rgba(0,10,12,0.72), rgba(255,77,109,0.08));
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.monitor[data-anomaly="active"] ~ .monitor-hud .operator-cue,
-.console-shell[data-tone="danger"] .operator-cue,
-.console-shell[data-tone="critical"] .operator-cue {
- border-color: rgba(255,209,102,0.72);
- box-shadow: 0 0 22px rgba(255,209,102,0.16), inset 0 1px 0 rgba(255,255,255,0.08);
-}
-.console-shell[data-tone="warn"] .monitor-hud span { border-color: rgba(255, 209, 102, 0.38); color: #ffeab2; }
-.console-shell[data-tone="danger"] .monitor-hud span,
-.console-shell[data-tone="critical"] .monitor-hud span {
- border-color: rgba(255, 77, 109, 0.46);
- color: #ffd6df;
- background: rgba(40, 0, 8, 0.76);
-}
-.console-shell[data-tone="danger"] .monitor,
-.console-shell[data-tone="critical"] .monitor { animation: monitorGlitch 900ms steps(2) infinite; }
-@keyframes monitorGlitch {
- 0%, 100% { transform: translate(0); filter: saturate(1); }
- 35% { transform: translate(1px, -1px); filter: saturate(1.4) hue-rotate(-8deg); }
- 70% { transform: translate(-1px, 1px); filter: saturate(1.25) hue-rotate(8deg); }
-}
-.scanline {
- position: absolute;
- left: 0;
- right: 0;
- top: -20%;
- height: 20%;
- background: linear-gradient(transparent, rgba(97,255,190,0.10), transparent);
- animation: scan 4s linear infinite;
-}
-@keyframes scan { to { top: 120%; } }
-
-.actions {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin-bottom: 12px;
-}
-.action-dock {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: minmax(0, 1fr) 92px;
- gap: 10px;
- align-items: stretch;
-}
-.action-guide {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- margin-top: 10px;
-}
-.directive-card {
- min-height: 72px;
- padding: 10px 11px;
- border: 1px solid rgba(255,255,255,0.10);
- border-radius: 14px;
- background:
- linear-gradient(135deg, rgba(255,209,102,0.10), transparent 46%),
- rgba(0, 10, 12, 0.72);
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
-}
-.directive-card strong,
-.directive-card span {
- display: block;
-}
-.directive-card strong {
- color: #ffeec2;
- font-size: 0.82rem;
- line-height: 1.2;
- letter-spacing: -0.01em;
-}
-.directive-card span {
- margin-top: 5px;
- color: rgba(216,255,243,0.68);
- font-size: 0.68rem;
- line-height: 1.35;
-}
-.more-actions-button {
- position: relative;
- min-height: 62px;
- padding: 8px 7px;
- text-align: center;
-}
-.more-actions-button[hidden] { display: none; }
-.more-actions-button .action-keycap {
- display: grid;
- grid-template-rows: 42px auto;
- gap: 5px;
- justify-items: center;
-}
-.more-actions-button .action-icon {
- font-size: 0.58rem;
- letter-spacing: 0.08em;
-}
-.more-action-count {
- position: absolute;
- right: 7px;
- top: 6px;
- min-width: 17px;
- height: 17px;
- padding: 0 5px;
- border-radius: 999px;
- display: grid;
- place-items: center;
- background: rgba(255,209,102,0.94);
- color: #140b00;
- font-size: 0.58rem;
- font-weight: 800;
- line-height: 1;
-}
-.secondary-actions-sheet[hidden] { display: none; }
-.secondary-actions-sheet {
- position: fixed;
- inset: 0;
- z-index: 80;
- display: grid;
- align-items: end;
- pointer-events: none;
-}
-.secondary-actions-backdrop {
- position: absolute;
- inset: 0;
- min-height: 0;
- border: 0;
- border-radius: 0;
- background: rgba(0,0,0,0.52);
- pointer-events: auto;
-}
-.secondary-actions-panel {
- position: relative;
- z-index: 1;
- width: min(100%, 520px);
- margin: 0 auto;
- padding: 18px;
- border: 1px solid rgba(97,255,190,0.28);
- border-radius: 22px 22px 0 0;
- background:
- linear-gradient(180deg, rgba(4,18,20,0.98), rgba(1,8,10,0.99)),
- var(--metal-texture) center / cover no-repeat;
- box-shadow: 0 -24px 64px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08);
- pointer-events: auto;
-}
-.secondary-actions-head {
- display: flex;
- justify-content: space-between;
- align-items: start;
- gap: 12px;
- margin-bottom: 14px;
-}
-.secondary-actions-head h2 {
- margin: 2px 0 0;
- font-size: 1.12rem;
- letter-spacing: -0.01em;
-}
-.sheet-close {
- min-height: 38px;
- padding: 8px 13px;
-}
-.secondary-actions {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 9px;
-}
-
-button {
- position: relative;
- min-height: 48px;
- border: 1px solid rgba(97,255,190,0.22);
- border-radius: 14px;
- padding: 13px 16px;
- color: #00130d;
- background: linear-gradient(135deg, rgba(97,255,190,0.98), rgba(81,214,255,0.95));
- box-shadow: 0 10px 28px rgba(81,214,255,0.12), inset 0 1px 0 rgba(255,255,255,0.42);
- font-family: "Microsoft YaHei", system-ui, sans-serif;
- font-weight: 950;
- cursor: pointer;
- transition: transform 120ms ease, filter 120ms ease, box-shadow 120ms ease;
-}
-button::before {
- content: "";
- position: absolute;
- left: 10px;
- top: 9px;
- width: 5px;
- height: 5px;
- border-radius: 999px;
- background: rgba(0, 19, 13, 0.55);
-}
-button:hover { transform: translateY(-1px); filter: brightness(1.08); box-shadow: 0 14px 34px rgba(81,214,255,0.18), inset 0 1px 0 rgba(255,255,255,0.5); }
-button:active { transform: translateY(1px); }
-button.secondary {
- width: 100%;
- color: var(--text);
- background:
- linear-gradient(135deg, rgba(255,77,109,0.16), rgba(255,209,102,0.08)),
- rgba(255,255,255,0.055);
- border: 1px solid rgba(255,209,102,0.28);
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
-}
-#forceAnomaly { color: #ffeec2; letter-spacing: 0.08em; }
-
-.logs {
- position: relative;
- z-index: 1;
- height: 224px;
- overflow: auto;
- margin: 0;
- padding: 14px 14px 14px 36px;
- border: 1px solid rgba(97,255,190,0.11);
- border-radius: 16px;
- background: rgba(0, 7, 8, 0.58);
- color: #bfeee0;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.88rem;
- line-height: 1.55;
-}
-.logs li { position: relative; margin: 2px 0; padding: 3px 8px 3px 4px; border-radius: 8px; }
-.logs li::marker { color: rgba(97,255,190,0.55); }
-.logs li.warn { color: var(--amber); }
-.logs li.danger { color: var(--red); }
-.logs li.ad { color: var(--cyan); }
-.logs li.success { color: var(--green); }
-.logs li.log-priority-high,
-.logs li.log-priority-special,
-.logs li.log-priority-success { font-weight: 900; text-shadow: 0 0 14px currentColor; }
-.logs li.log-priority-high { background: linear-gradient(90deg, rgba(255,77,109,0.14), transparent 72%); box-shadow: inset 3px 0 0 rgba(255,77,109,0.72); }
-.logs li.log-priority-special { background: linear-gradient(90deg, rgba(81,214,255,0.12), transparent 72%); box-shadow: inset 3px 0 0 rgba(81,214,255,0.68); }
-.logs li.log-priority-success { background: linear-gradient(90deg, rgba(97,255,190,0.12), transparent 72%); box-shadow: inset 3px 0 0 rgba(97,255,190,0.72); }
-.logs li.log-priority-medium { background: linear-gradient(90deg, rgba(255,209,102,0.09), transparent 72%); }
-
-.start-overlay {
- position: fixed;
- inset: 0;
- z-index: 18;
- display: grid;
- align-items: center;
- justify-items: start;
- padding: 20px;
- overflow: auto;
- background:
- linear-gradient(90deg, rgba(0,0,0,0.78), rgba(0,0,0,0.34) 42%, rgba(0,0,0,0.12) 76%),
- radial-gradient(circle at 62% 36%, rgba(255,77,109,0.10), transparent 20rem);
- backdrop-filter: blur(1.5px);
-}
-.start-card {
- width: min(520px, 92vw);
- max-height: calc(100dvh - 28px);
- overflow: auto;
- padding: 30px;
- border: 1px solid rgba(97, 255, 190, 0.44);
- border-radius: 28px;
- background:
- linear-gradient(180deg, rgba(5, 19, 22, 0.78), rgba(0, 4, 6, 0.90)),
- var(--hud-glass-texture) center / cover no-repeat;
- box-shadow: 0 34px 110px rgba(0,0,0,0.68), 0 0 60px rgba(97,255,190,0.14);
-}
-.start-card h2 { font-size: clamp(2rem, 7vw, 3.6rem); line-height: 0.98; letter-spacing: -0.06em; color: #eafff8; text-shadow: 0 0 28px rgba(97,255,190,0.34); }
-.start-copy { margin: 16px 0; color: #bffff0; font-size: 1.05rem; line-height: 1.65; }
-.start-checklist {
- display: grid;
- gap: 8px;
- margin: 0 0 18px;
- padding: 14px 14px 14px 34px;
- border: 1px solid rgba(97,255,190,0.14);
- border-radius: 16px;
- background: rgba(0, 8, 10, 0.42);
- color: var(--muted);
- font-family: "Cascadia Mono", Consolas, monospace;
-}
-.start-checklist li::marker { color: var(--green); }
-.start-rules { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 18px; font-family: "Cascadia Mono", Consolas, monospace; }
-.start-rules span { color: var(--amber); font-size: 0.75rem; font-weight: 900; letter-spacing: 0.12em; }
-.start-rules b {
- padding: 7px 10px;
- border: 1px solid rgba(255, 77, 109, 0.34);
- border-radius: 999px;
- color: #ffd6df;
- background: rgba(255, 77, 109, 0.08);
- font-size: 0.78rem;
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
-}
-.start-card button { width: 100%; min-height: 58px; font-size: 1.04rem; letter-spacing: 0.08em; }
-
-.failure-overlay {
- position: fixed;
- inset: 0;
- z-index: 20;
- display: grid;
- place-items: center;
- padding: 20px;
- overflow: auto;
- background: rgba(0,0,0,0.74);
- backdrop-filter: blur(8px);
-}
-.failure-card {
- width: min(560px, 100%);
- max-height: calc(100dvh - 28px);
- overflow: auto;
- padding: 28px;
- border: 1px solid rgba(255, 77, 109, 0.58);
- border-radius: 26px;
- background:
- linear-gradient(180deg, rgba(38, 5, 12, 0.88), rgba(7, 8, 10, 0.98)),
- var(--signal-tear-texture) center / cover no-repeat,
- linear-gradient(180deg, rgba(38, 5, 12, 0.98), rgba(7, 8, 10, 0.98));
- box-shadow: 0 30px 90px rgba(0,0,0,0.62), 0 0 50px rgba(255,77,109,0.12);
-}
-.failure-card h2 { font-size: clamp(2.2rem, 8vw, 3.3rem); color: var(--red); text-shadow: 0 0 22px rgba(255,77,109,0.34); }
-.failure-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 14px 0; }
-.failure-metrics span { padding: 10px; border: 1px solid rgba(255, 77, 109, 0.28); border-radius: 14px; background: rgba(255, 77, 109, 0.07); font-family: "Cascadia Mono", Consolas, monospace; }
-.failure-metrics b,
-.failure-metrics strong { display: block; }
-.failure-metrics b { color: rgba(255, 214, 223, 0.72); font-size: 0.68rem; letter-spacing: 0.08em; }
-.failure-metrics strong { margin-top: 4px; color: #fff1f4; font-size: 1.28rem; }
-.post-run-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin: 10px 0 0; }
-.post-run-summary span { padding: 8px; border: 1px solid rgba(212, 186, 255, 0.28); border-radius: 12px; background: rgba(167, 139, 250, 0.07); font-family: "Cascadia Mono", Consolas, monospace; }
-.post-run-summary b,
-.post-run-summary strong { display: block; }
-.post-run-summary b { color: rgba(212, 186, 255, 0.68); font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; }
-.post-run-summary strong { color: var(--accent); font-size: 0.96rem; margin-top: 2px; }
-.archive-card { border-color: rgba(167, 139, 250, 0.55); background: linear-gradient(180deg, rgba(15, 8, 30, 0.98), rgba(5, 3, 15, 0.98)); max-height: 90dvh; overflow-y: auto; }
-.archive-card h2 { color: var(--accent); text-shadow: 0 0 22px rgba(167, 139, 250, 0.25); }
-.archive-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin: 10px 0; }
-.archive-stats span { padding: 8px; border: 1px solid rgba(167, 139, 250, 0.22); border-radius: 12px; background: rgba(167, 139, 250, 0.05); }
-.archive-stats b { display: block; color: rgba(212, 186, 255, 0.6); font-size: 0.6rem; letter-spacing: 0.06em; text-transform: uppercase; }
-.archive-stats strong { display: block; color: #e2d4ff; font-size: 1.05rem; margin-top: 2px; }
-.archive-list { margin: 10px 0; }
-.anomaly-entry { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border: 1px solid rgba(167, 139, 250, 0.15); border-radius: 10px; margin: 4px 0; background: rgba(167, 139, 250, 0.04); }
-.anomaly-entry span { color: #c8b8f0; font-size: 0.82rem; }
-.anomaly-entry strong { color: var(--accent); font-size: 0.88rem; }
-.hint { color: var(--amber); }
-.failure-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
-
-.fake-ending .ending-card { border-color: rgba(255, 0, 80, 0.72); background: linear-gradient(180deg, rgba(60, 0, 12, 0.98), rgba(5, 0, 2, 0.98)); animation: endingPulse 2s ease-in-out infinite; }
-@keyframes endingPulse { 0%, 100% { box-shadow: 0 30px 90px rgba(255, 0, 80, 0.25); } 50% { box-shadow: 0 30px 120px rgba(255, 0, 80, 0.45); } }
-.fake-ending h2 { color: #ff0050; text-shadow: 0 0 30px rgba(255, 0, 80, 0.5); animation: endingFlicker 3s steps(1) infinite; }
-@keyframes endingFlicker { 0%, 90% { opacity: 1; } 95% { opacity: 0.2; } 100% { opacity: 1; } }
-.ending-text,
-.ending-truth { white-space: pre-wrap; font-family: "Cascadia Mono", Consolas, monospace; line-height: 1.58; padding: 12px; border-radius: 12px; margin: 0 0 10px; }
-.ending-text { color: #ff6b8a; background: rgba(255, 0, 80, 0.06); border: 1px solid rgba(255, 0, 80, 0.22); }
-.ending-truth { color: #bffff0; background: rgba(97, 255, 190, 0.06); border: 1px solid rgba(97, 255, 190, 0.2); }
-
-@media (max-width: 920px) {
- .topbar { grid-template-columns: 1fr; }
- .topbar::after { display: none; }
- .shift-card { text-align: left; }
- .grid { grid-template-columns: 1fr; grid-template-areas: "monitor" "actions" "status" "logs"; }
- .monitor-panel { min-height: 300px; }
- .start-overlay { align-items: end; justify-items: center; background: linear-gradient(180deg, rgba(0,0,0,0.10), rgba(0,0,0,0.76)); }
-}
-
-/* Android/H5 portrait console: one viewport, no page scroll. Panels with overflow scroll internally. */
-@media (max-width: 700px) and (orientation: portrait) {
- html, body { height: 100%; overflow: hidden; }
- .console-shell {
- width: 100vw;
- height: 100vh;
- height: 100dvh;
- min-height: 0;
- display: grid;
- grid-template-rows: auto minmax(0, 1fr);
- gap: 6px;
- padding: max(6px, env(safe-area-inset-top)) 6px max(6px, env(safe-area-inset-bottom));
- overflow: hidden;
- }
- .topbar {
- grid-template-columns: minmax(0, 1fr) 76px;
- align-items: center;
- margin-bottom: 0;
- padding: 7px 9px;
- gap: 8px;
- border-radius: 14px;
- }
- .eyebrow { display: none; }
- h1 { font-size: clamp(1.15rem, 7vw, 1.62rem); line-height: 1; letter-spacing: -0.05em; }
- .shift-card { min-width: 0; padding: 6px 7px; border-radius: 12px; text-align: center; }
- .shift-card span { font-size: 0.52rem; letter-spacing: 0.04em; }
- .shift-card strong { font-size: 1.52rem; }
- .grid {
- min-height: 0;
- height: 100%;
- display: grid;
- grid-template-columns: 1fr;
- grid-template-rows: minmax(225px, 1.12fr) auto auto minmax(74px, 0.56fr);
- grid-template-areas: "monitor" "actions" "status" "logs";
- gap: 6px;
- overflow: hidden;
- }
- .panel {
- min-height: 0;
- padding: 8px;
- border-radius: 14px;
- box-shadow: 0 12px 34px rgba(0,0,0,0.34), inset 0 1px 0 rgba(255,255,255,0.05);
- }
- .panel::before { inset: 5px; border-radius: 10px; }
- .panel-title { margin-bottom: 6px; font-size: 0.68rem; letter-spacing: 0.08em; }
- .monitor-panel { min-height: 0; }
- .monitor { min-height: 0; height: calc(100% - 26px); padding: 8px; gap: 6px; border-radius: 12px; }
- .monitor::before, .monitor::after { top: 7px; font-size: 0.52rem; }
- .monitor::before { left: 10px; }
- .monitor::after { right: 10px; }
- .cctv-stage { min-height: 0; height: 100%; border-radius: 11px; }
- .camera-frame { inset: 10px; border-radius: 10px; }
- .elevator-car { width: 46%; height: 68%; }
- .passenger-heat { width: 26px; height: 54px; }
- .floor-indicator { font-size: 0.58rem; padding: 3px 6px; }
- .anomaly-reticle { width: 38px; height: 38px; }
- .monitor-caption {
- max-height: 3.35em;
- padding: 6px 8px;
- font-size: 0.72rem;
- line-height: 1.28;
- text-align: left;
- }
- .monitor-hud {
- left: 14px;
- right: 14px;
- bottom: 12px;
- font-size: 0.5rem;
- letter-spacing: 0.03em;
- }
- .monitor-hud span { padding: 4px 5px; }
- .action-dock { grid-template-columns: minmax(0, 1fr) 66px; gap: 5px; }
- .action-guide { display: none; }
- .actions { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-bottom: 5px; }
- button { min-height: 34px; padding: 6px 5px; border-radius: 10px; font-size: 0.76rem; }
- .more-actions-button { min-height: 34px; padding: 5px 3px; }
- button::before { display: none; }
- #forceAnomaly { min-height: 30px; font-size: 0.68rem; }
- .status-list { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; }
- .status-list div { min-height: 38px; padding: 5px 4px; border-radius: 9px; }
- .status-list div::after { display: none; }
- dt { font-size: 0.5rem; letter-spacing: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- dd { margin-top: 2px; font-size: 0.82rem; line-height: 1.05; }
- meter { display: none; }
- .logs { height: 100%; min-height: 0; padding: 7px 8px 7px 24px; border-radius: 11px; font-size: 0.66rem; line-height: 1.25; }
- .logs li { margin: 0; padding: 1px 4px 1px 2px; }
- .start-overlay, .failure-overlay { place-items: center; padding: max(10px, env(safe-area-inset-top)) 10px max(10px, env(safe-area-inset-bottom)); }
- .start-card, .failure-card { padding: 16px; border-radius: 18px; }
- .start-card h2 { font-size: clamp(1.65rem, 10vw, 2.5rem); }
- .start-copy { margin: 10px 0; font-size: 0.9rem; line-height: 1.45; }
- .start-checklist { gap: 5px; margin-bottom: 10px; padding: 10px 10px 10px 28px; font-size: 0.78rem; }
- .failure-metrics, .failure-actions { grid-template-columns: 1fr; }
-}
-
-@media (max-height: 480px) and (orientation: landscape) {
- .console-shell { padding-top: 8px; }
- .topbar { grid-template-columns: minmax(0, 1fr) 140px; padding: 10px 12px; }
- h1 { font-size: clamp(1.8rem, 7vw, 3rem); }
- .shift-card strong { font-size: 2rem; }
- .monitor-panel { min-height: 200px; }
- .start-overlay, .failure-overlay { place-items: start center; overflow: auto; }
-}
-
-
-/* Monitor visual pass: richer CCTV feed, camera metadata, sensor noise, and multi-view context. */
-.camera-label,
-.camera-timecode {
- position: absolute;
- z-index: 5;
- top: 12px;
- padding: 4px 7px;
- border: 1px solid rgba(97,255,190,0.18);
- border-radius: 6px;
- color: rgba(210,255,244,0.84);
- background: rgba(0,10,12,0.58);
- font: 700 0.62rem/1 "Cascadia Mono", Consolas, monospace;
- letter-spacing: 0.12em;
- text-shadow: 0 0 10px rgba(97,255,190,0.34);
-}
-.camera-label { left: 14px; }
-.camera-timecode { right: 14px; color: rgba(255,209,102,0.88); }
-.cctv-noise {
- position: absolute;
- inset: 0;
- z-index: 4;
- pointer-events: none;
- background:
- repeating-radial-gradient(circle at 18% 24%, rgba(255,255,255,0.08) 0 1px, transparent 1px 4px),
- repeating-linear-gradient(90deg, rgba(97,255,190,0.025) 0 1px, transparent 1px 3px);
- mix-blend-mode: screen;
- opacity: var(--cctv-noise, 0.18);
- animation: sensorNoise 460ms steps(2) infinite;
-}
-@keyframes sensorNoise { 50% { transform: translate(1px, -1px); opacity: 0.27; } }
-.camera-frame {
- right: 124px;
- background:
- linear-gradient(180deg, rgba(0,7,8,0.08), rgba(0,0,0,0.30)),
- radial-gradient(ellipse at 50% 78%, rgba(81,214,255,0.08), transparent 46%);
-}
-.hall-perspective {
- position: absolute;
- inset: 16% 8% 0;
- transform: perspective(520px) rotateX(62deg);
- transform-origin: bottom center;
- background:
- linear-gradient(90deg, rgba(97,255,190,0.15) 1px, transparent 1px),
- linear-gradient(0deg, rgba(97,255,190,0.13) 1px, transparent 1px),
- radial-gradient(ellipse at 50% 100%, rgba(97,255,190,0.16), transparent 58%);
- background-size: 34px 100%, 100% 28px, auto;
- border-top: 1px solid rgba(97,255,190,0.18);
- opacity: 0.72;
-}
-.hall-perspective span {
- position: absolute;
- bottom: 6%;
- width: 1px;
- height: 110%;
- background: linear-gradient(180deg, transparent, rgba(97,255,190,0.32));
- transform-origin: bottom center;
-}
-.hall-perspective span:nth-child(1) { left: 18%; transform: rotate(-17deg); }
-.hall-perspective span:nth-child(2) { left: 50%; opacity: 0.7; }
-.hall-perspective span:nth-child(3) { right: 18%; transform: rotate(17deg); }
-.doorway-depth {
- position: absolute;
- left: 50%;
- bottom: 8%;
- width: min(62%, 290px);
- height: 78%;
- transform: translateX(-50%);
- border: 1px solid rgba(97,255,190,0.16);
- border-bottom-color: rgba(255,209,102,0.20);
- background:
- linear-gradient(90deg, rgba(81,214,255,0.10), transparent 18% 82%, rgba(81,214,255,0.10)),
- linear-gradient(180deg, rgba(255,255,255,0.05), transparent 38%);
- clip-path: polygon(12% 0, 88% 0, 100% 100%, 0 100%);
- box-shadow: inset 0 0 38px rgba(0,0,0,0.62), 0 0 34px rgba(81,214,255,0.08);
-}
-.shaft-lines { opacity: 0.48; }
-.elevator-car::before {
- content: "";
- position: absolute;
- inset: 10px 12px;
- border: 1px solid rgba(255,255,255,0.08);
- background:
- linear-gradient(90deg, transparent 48%, rgba(97,255,190,0.22) 49% 51%, transparent 52%),
- repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 18px);
-}
-.cctv-multiview {
- position: absolute;
- z-index: 3;
- top: 44px;
- right: 16px;
- bottom: 16px;
- width: 96px;
- display: grid;
- grid-template-rows: repeat(3, 1fr);
- gap: 7px;
-}
-.cctv-multiview div {
- position: relative;
- overflow: hidden;
- border: 1px solid rgba(97,255,190,0.17);
- border-radius: 8px;
- background:
- repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 5px),
- radial-gradient(circle at 52% 50%, rgba(97,255,190,0.16), transparent 34%),
- #02090a;
- box-shadow: inset 0 0 20px rgba(0,0,0,0.8);
-}
-.cctv-multiview b {
- position: absolute;
- left: 6px;
- top: 5px;
- z-index: 2;
- font: 700 0.48rem/1 "Cascadia Mono", Consolas, monospace;
- letter-spacing: 0.08em;
- color: rgba(216,255,243,0.76);
-}
-.cctv-multiview i {
- position: absolute;
- left: 50%;
- top: 54%;
- width: 34px;
- height: 22px;
- transform: translate(-50%, -50%);
- border: 1px solid rgba(97,255,190,0.28);
- border-radius: 4px;
- box-shadow: 0 0 16px rgba(97,255,190,0.12);
-}
-.cctv-multiview div:nth-child(2) i {
- width: 56px;
- height: 1px;
- border: 0;
- background: rgba(255,209,102,0.42);
- box-shadow: 0 0 14px rgba(255,209,102,0.22);
-}
-.cctv-multiview div:nth-child(3) {
- background:
- radial-gradient(circle at 50% 45%, rgba(255,209,102,0.72), transparent 10%),
- radial-gradient(ellipse at 50% 70%, rgba(255,77,109,0.46), transparent 30%),
- #070405;
-}
-@media (max-width: 700px) and (orientation: portrait) {
- .camera-label, .camera-timecode { top: 7px; font-size: 0.48rem; padding: 3px 5px; }
- .camera-frame { right: 78px; }
- .cctv-multiview { top: 32px; right: 10px; bottom: 10px; width: 60px; gap: 5px; }
- .cctv-multiview b { font-size: 0.38rem; left: 4px; top: 4px; }
- .cctv-multiview i { width: 22px; height: 16px; }
- .doorway-depth { width: 74%; }
-}
-
-
-/* Realistic CCTV anomaly redesign: anomalies are embedded in the scene, not drawn as centered HUD icons. */
-.scene-vignette,
-.door-gap-glow,
-.distant-shadow,
-.thermal-ghost,
-.detection-corners {
- position: absolute;
- pointer-events: none;
-}
-.camera-frame {
- --cctv-target-x: 50%;
- --cctv-target-y: 50%;
- --cctv-target-top: 22%;
- --cctv-target-height: 58%;
-}
-.scene-vignette {
- inset: 0;
- background:
- radial-gradient(circle at 50% 58%, transparent 0 38%, rgba(0,0,0,0.44) 72%, rgba(0,0,0,0.76) 100%),
- linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.34));
- z-index: 1;
-}
-.door-gap-glow {
- left: var(--cctv-target-x);
- top: var(--cctv-target-top);
- width: 9px;
- height: var(--cctv-target-height);
- transform: translateX(-50%);
- z-index: 2;
- border-radius: 999px;
- background:
- linear-gradient(180deg, transparent, rgba(255,77,109,0.14) 16%, rgba(255,77,109,0.82) 52%, rgba(255,77,109,0.10) 88%, transparent),
- linear-gradient(90deg, transparent, rgba(255,209,102,0.55), transparent);
- filter: blur(1.2px) drop-shadow(0 0 16px rgba(255,77,109,0.52));
- opacity: 0;
-}
-.distant-shadow {
- left: var(--cctv-target-x);
- top: var(--cctv-target-y);
- width: 72px;
- height: 126px;
- transform: translate(-50%, -50%) skewX(-7deg) scale(0.82);
- z-index: 2;
- border-radius: 44% 48% 18% 20%;
- background:
- radial-gradient(circle at 50% 16%, rgba(0,0,0,0.72) 0 12px, transparent 14px),
- radial-gradient(ellipse at 50% 58%, rgba(0,0,0,0.62), rgba(0,0,0,0.22) 54%, transparent 66%);
- filter: blur(1.4px);
- opacity: 0;
- mix-blend-mode: multiply;
-}
-.thermal-ghost {
- left: var(--cctv-target-x);
- top: calc(var(--cctv-target-y) + 18px);
- width: 46px;
- height: 92px;
- transform: translate(-50%, -50%);
- z-index: 3;
- border-radius: 999px 999px 32px 32px;
- background:
- radial-gradient(circle at 50% 16%, rgba(255,209,102,0.72) 0 10px, transparent 13px),
- radial-gradient(ellipse at 50% 62%, rgba(255,77,109,0.55), rgba(255,209,102,0.12) 44%, transparent 65%),
- repeating-linear-gradient(0deg, rgba(255,77,109,0.28) 0 2px, transparent 2px 7px);
- filter: blur(0.6px) drop-shadow(0 0 14px rgba(255,77,109,0.42));
- opacity: 0;
- mask-image: linear-gradient(180deg, #000 0 66%, transparent 100%);
-}
-.detection-corners {
- left: var(--cctv-target-x);
- top: var(--cctv-target-y);
- width: 92px;
- height: 154px;
- z-index: 4;
- transform: translate(-50%, -50%);
- opacity: 0;
- background:
- linear-gradient(var(--red), var(--red)) left top / 28px 1px no-repeat,
- linear-gradient(var(--red), var(--red)) left top / 1px 22px no-repeat,
- linear-gradient(var(--red), var(--red)) right top / 28px 1px no-repeat,
- linear-gradient(var(--red), var(--red)) right top / 1px 22px no-repeat,
- linear-gradient(var(--red), var(--red)) left bottom / 28px 1px no-repeat,
- linear-gradient(var(--red), var(--red)) left bottom / 1px 22px no-repeat,
- linear-gradient(var(--red), var(--red)) right bottom / 28px 1px no-repeat,
- linear-gradient(var(--red), var(--red)) right bottom / 1px 22px no-repeat;
- filter: drop-shadow(0 0 8px rgba(255,77,109,0.34));
-}
-.monitor[data-anomaly="active"] .door-gap-glow {
- opacity: 0.92;
- animation: doorGapPulse 760ms ease-in-out infinite;
-}
-.monitor[data-anomaly="active"] .distant-shadow {
- opacity: 0.50;
- animation: shadowDrift 1.4s steps(2) infinite;
-}
-.monitor[data-anomaly="active"] .thermal-ghost {
- opacity: 0.74;
- animation: thermalBreak 900ms steps(2) infinite;
-}
-.monitor[data-anomaly="active"] .detection-corners {
- opacity: 0.96;
- animation: detectionBlink 620ms steps(2) infinite;
-}
-.monitor[data-passengers="missing"] .thermal-ghost {
- opacity: 0.18;
- filter: grayscale(1) blur(1.5px) drop-shadow(0 0 18px rgba(81,214,255,0.38));
-}
-@keyframes doorGapPulse { 50% { filter: blur(2px) drop-shadow(0 0 24px rgba(255,77,109,0.84)); transform: translateX(calc(-50% + 1px)); } }
-@keyframes shadowDrift { 50% { transform: translate(calc(-50% + 5px), calc(-50% - 2px)) skewX(-10deg) scale(0.86); } }
-@keyframes thermalBreak { 50% { clip-path: polygon(0 0, 100% 0, 100% 22%, 18% 22%, 18% 35%, 100% 35%, 100% 100%, 0 100%); } }
-@keyframes detectionBlink { 50% { opacity: 0.28; transform: translate(calc(-50% + 2px), -50%); } }
-
-/* CCTV anomaly-state layers: all readable UI remains DOM text; these layers only add camera artifacts. */
-.signal-tear-layer,
-.freeze-frame,
-.infrared-flicker,
-.snow-burst {
- position: absolute;
- inset: 0;
- z-index: 6;
- pointer-events: none;
- opacity: 0;
-}
-.signal-tear-layer {
- background:
- var(--signal-tear-texture) center / cover no-repeat,
- linear-gradient(0deg, transparent 0 18%, rgba(255,77,109,0.30) 18% 20%, transparent 20% 42%, rgba(81,214,255,0.22) 42% 43%, transparent 43% 100%);
- mix-blend-mode: screen;
-}
-.freeze-frame {
- background:
- linear-gradient(rgba(216,255,243,0.13), rgba(0,0,0,0.10)),
- repeating-linear-gradient(0deg, rgba(255,255,255,0.08) 0 1px, transparent 1px 3px);
- mix-blend-mode: screen;
-}
-.infrared-flicker {
- background:
- radial-gradient(circle at var(--cctv-target-x, 50%) 46%, rgba(255,209,102,0.35), transparent 18%),
- radial-gradient(ellipse at var(--cctv-target-x, 50%) 58%, rgba(255,77,109,0.42), transparent 32%);
- mix-blend-mode: color-dodge;
-}
-.snow-burst {
- background:
- var(--cctv-noise-texture) center / cover no-repeat,
- repeating-radial-gradient(circle at 20% 35%, rgba(255,255,255,0.18) 0 1px, transparent 1px 3px),
- repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 4px);
- mix-blend-mode: screen;
-}
-.monitor[data-anomaly="active"] .signal-tear-layer {
- opacity: 0.72;
- animation: signalTearBurst 520ms steps(3) infinite;
-}
-.monitor[data-anomaly="active"] .freeze-frame {
- opacity: 0.22;
- animation: cctvFreezeFrame 1.4s steps(1) infinite;
-}
-.monitor[data-anomaly="active"] .infrared-flicker {
- opacity: 0.58;
- animation: infraredFlicker 680ms steps(2) infinite;
-}
-.monitor[data-anomaly="active"] .snow-burst {
- opacity: calc(var(--cctv-noise, 0.18) * 0.82);
- animation: signalSnowBurst 360ms steps(2) infinite;
-}
-.monitor[data-shake="true"] .cctv-stage { animation: cctvStageShake 240ms steps(2) infinite; }
-.actions button[data-recommended="true"],
-.secondary-actions button[data-recommended="true"],
-.more-actions-button[data-recommended="true"] {
- border-color: rgba(255,209,102,0.86);
- box-shadow: 0 0 0 2px rgba(255,209,102,0.18), 0 0 28px rgba(255,209,102,0.32), inset 0 1px 0 rgba(255,255,255,0.42);
-}
-@keyframes signalTearBurst {
- 0%, 100% { transform: translate(0, 0) scaleX(1); filter: hue-rotate(0deg); }
- 33% { transform: translate(5px, -1px) scaleX(1.012); filter: hue-rotate(-16deg); }
- 66% { transform: translate(-4px, 2px) scaleX(0.992); filter: hue-rotate(14deg); }
-}
-@keyframes cctvFreezeFrame {
- 0%, 78%, 100% { opacity: 0; }
- 79%, 88% { opacity: 0.34; filter: contrast(1.6) saturate(0.45); }
-}
-@keyframes infraredFlicker {
- 0%, 100% { filter: hue-rotate(0deg) saturate(1); transform: scale(1); }
- 50% { filter: hue-rotate(-24deg) saturate(1.8); transform: scale(1.01); }
-}
-@keyframes signalSnowBurst {
- 50% { transform: translate(-1px, 1px); opacity: calc(var(--cctv-noise, 0.18) * 0.96); }
-}
-@keyframes cctvStageShake {
- 50% { transform: translate(2px, -1px); }
-}
-
-.shaft-lines,
-.doorway-depth,
-.elevator-car,
-.anomaly-reticle,
-.passenger-heat {
- display: none !important;
-}
-
-
-/* Animated CCTV loop and elevator-matched controls. */
-.cctv-loop {
- position: absolute;
- inset: 0;
- z-index: 2;
- pointer-events: none;
- background:
- radial-gradient(circle at var(--cctv-target-x) calc(var(--cctv-target-y) - 18px), rgba(255,209,102,0.22), transparent 8%),
- linear-gradient(90deg, transparent 0 46%, rgba(255,77,109,0.13) 49%, rgba(255,209,102,0.18) 50%, rgba(255,77,109,0.13) 51%, transparent 55%),
- repeating-linear-gradient(180deg, transparent 0 11px, rgba(255,255,255,0.04) 11px 12px);
- mix-blend-mode: screen;
- opacity: 0;
- animation: cctvDoorLoop 1.45s steps(4) infinite, cameraMicroShake 5.5s steps(2) infinite;
-}
-.cctv-loop::before,
-.cctv-loop::after {
- content: "";
- position: absolute;
- pointer-events: none;
-}
-.cctv-loop::before {
- left: calc(var(--cctv-target-x) - 42px);
- top: calc(var(--cctv-target-y) - 54px);
- width: 84px;
- height: 138px;
- border: 1px solid rgba(255,77,109,0.42);
- border-radius: 4px;
- box-shadow: 0 0 14px rgba(255,77,109,0.18), inset 0 0 18px rgba(255,77,109,0.08);
- animation: detectionLoop 1.1s steps(2) infinite;
-}
-.cctv-loop::after {
- left: calc(var(--cctv-target-x) - 17px);
- top: calc(var(--cctv-target-y) - 24px);
- width: 34px;
- height: 72px;
- border-radius: 999px 999px 18px 18px;
- background:
- radial-gradient(circle at 50% 15%, rgba(255,209,102,0.72), transparent 34%),
- radial-gradient(ellipse at 50% 68%, rgba(255,77,109,0.46), transparent 62%);
- filter: blur(0.7px) drop-shadow(0 0 12px rgba(255,77,109,0.42));
- animation: thermalGhostLoop 1.25s steps(3) infinite;
-}
-@keyframes cctvDoorLoop {
- 0%, 100% { opacity: 0.34; filter: hue-rotate(0deg) contrast(1); transform: translateX(0); }
- 25% { opacity: 0.66; filter: hue-rotate(-8deg) contrast(1.18); transform: translateX(1px); }
- 50% { opacity: 0.48; filter: hue-rotate(6deg) contrast(0.92); transform: translateX(-1px); }
- 75% { opacity: 0.76; filter: hue-rotate(-12deg) contrast(1.28); transform: translateX(2px); }
-}
-@keyframes detectionLoop {
- 50% { border-color: rgba(255,209,102,0.58); transform: scale(1.03); opacity: 0.54; }
-}
-@keyframes thermalGhostLoop {
- 0%, 100% { opacity: 0.34; transform: translateY(0) scaleY(1); }
- 50% { opacity: 0.72; transform: translateY(-3px) scaleY(1.06); }
-}
-@keyframes cameraMicroShake {
- 50% { translate: 1px -1px; }
-}
-
-.actions button,
-.secondary-actions button {
- display: grid;
- grid-template-columns: 46px minmax(0, 1fr);
- align-items: center;
- gap: 10px;
- min-height: 62px;
- padding: 8px 12px 8px 8px;
- text-align: left;
-}
-.actions button::before,
-.secondary-actions button::before { display: none; }
-.action-icon {
- display: grid;
- place-items: center;
- min-width: 42px;
- height: 42px;
- border: 1px solid rgba(0,19,13,0.22);
- border-radius: 12px;
- background: rgba(0,19,13,0.16);
- color: #00130d;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 1.28rem;
- font-weight: 1000;
- line-height: 1;
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.24), 0 0 12px rgba(0,0,0,0.12);
-}
-.action-label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.actions button[data-action="openDoor"] .action-icon,
-.actions button[data-action="closeDoor"] .action-icon,
-.secondary-actions button[data-action="openDoor"] .action-icon,
-.secondary-actions button[data-action="closeDoor"] .action-icon {
- letter-spacing: -0.18em;
- padding-right: 0.18em;
-}
-.actions button[data-action="moveUp"] .action-icon,
-.actions button[data-action="moveDown"] .action-icon,
-.secondary-actions button[data-action="moveUp"] .action-icon,
-.secondary-actions button[data-action="moveDown"] .action-icon {
- color: #001a24;
- background: rgba(81,214,255,0.22);
-}
-.actions button[data-action="emergencyStop"],
-.secondary-actions button[data-action="emergencyStop"] {
- background: linear-gradient(135deg, rgba(255,77,109,0.96), rgba(255,209,102,0.88));
-}
-.actions button[data-action="restartSystem"] .action-icon,
-.secondary-actions button[data-action="restartSystem"] .action-icon { animation: restartIconSpin 2.4s linear infinite; }
-@keyframes restartIconSpin { to { rotate: 360deg; } }
-
-
-/* Stronger elevator-control key readability. */
-.actions button,
-.secondary-actions button,
-.more-actions-button {
- border-color: rgba(97,255,190,0.34);
- background:
- linear-gradient(180deg, rgba(216,255,243,0.98), rgba(97,255,190,0.92) 52%, rgba(81,214,255,0.88)),
- radial-gradient(circle at 50% 0%, rgba(255,255,255,0.62), transparent 58%);
-}
-.action-icon {
- height: 46px;
- min-width: 50px;
- color: #ecfff9;
- background:
- linear-gradient(180deg, rgba(0,31,24,0.94), rgba(0,12,16,0.98));
- border-color: rgba(97,255,190,0.38);
- text-shadow: 0 0 10px rgba(97,255,190,0.78);
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 0 18px rgba(97,255,190,0.22);
-}
-.actions button[data-action="openDoor"] .action-icon,
-.actions button[data-action="closeDoor"] .action-icon,
-.secondary-actions button[data-action="openDoor"] .action-icon,
-.secondary-actions button[data-action="closeDoor"] .action-icon {
- font-size: 0.96rem;
- letter-spacing: -0.16em;
- color: #d8fff3;
-}
-.actions button[data-action="moveUp"] .action-icon,
-.actions button[data-action="moveDown"] .action-icon,
-.secondary-actions button[data-action="moveUp"] .action-icon,
-.secondary-actions button[data-action="moveDown"] .action-icon {
- font-size: 1.62rem;
- color: #bff5ff;
- background: linear-gradient(180deg, rgba(0,39,48,0.96), rgba(0,11,18,0.98));
- text-shadow: 0 0 14px rgba(81,214,255,0.86);
-}
-.actions button[data-action="emergencyStop"] .action-icon,
-.secondary-actions button[data-action="emergencyStop"] .action-icon {
- color: #fff1f4;
- background: linear-gradient(180deg, rgba(102,0,20,0.96), rgba(28,0,8,0.98));
- border-color: rgba(255,77,109,0.68);
- font-size: 0.68rem;
- letter-spacing: 0.04em;
- text-shadow: 0 0 12px rgba(255,77,109,0.95);
-}
-.actions button[data-action="inspectLog"] .action-icon,
-.actions button[data-action="unlockHiddenLog"] .action-icon,
-.secondary-actions button[data-action="inspectLog"] .action-icon,
-.secondary-actions button[data-action="unlockHiddenLog"] .action-icon {
- font-size: 0.76rem;
- letter-spacing: 0.04em;
- color: #ffeec2;
- text-shadow: 0 0 12px rgba(255,209,102,0.82);
-}
-
-
-/* YOLO visual-density pass: game HUD over text-heavy menu. */
-.game-title {
- font-size: clamp(2rem, 4.8vw, 4.4rem);
- max-width: 620px;
-}
-.topbar {
- grid-template-columns: minmax(0, 1fr) 132px;
- padding: 10px 12px;
- margin-bottom: 10px;
-}
-.topbar::after {
- content: "CCTV / LIFT / NIGHT SHIFT";
- right: 160px;
- top: 12px;
- opacity: 0.72;
-}
-.eyebrow { font-size: 0.64rem; letter-spacing: 0.2em; }
-.shift-card {
- min-width: 116px;
- padding: 10px;
- border-radius: 18px;
-}
-.shift-card span { font-size: 0.62rem; }
-.shift-card strong { font-size: 2.5rem; }
-.grid {
- grid-template-columns: 0.66fr 1.34fr;
- grid-template-areas:
- "status monitor"
- "actions monitor"
- "logs logs";
- gap: 10px;
-}
-.panel { padding: 14px; border-radius: 20px; }
-.panel-title {
- margin-bottom: 10px;
- font-size: 0.78rem;
- letter-spacing: 0.12em;
- opacity: 0.86;
-}
-.status-panel { min-height: 0; }
-.status-list {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
-}
-.status-list div {
- min-height: 66px;
- display: grid;
- grid-template-columns: 30px 1fr;
- grid-template-areas:
- "icon label"
- "icon value";
- align-items: center;
- gap: 0 8px;
- padding: 8px;
- border-radius: 14px;
- background:
- radial-gradient(circle at 16px 18px, rgba(97,255,190,0.14), transparent 42px),
- rgba(0, 8, 10, 0.56);
-}
-.hud-icon {
- grid-area: icon;
- display: grid;
- place-items: center;
- width: 30px;
- height: 42px;
- border: 1px solid rgba(97,255,190,0.24);
- border-radius: 10px;
- color: #d8fff3;
- background: rgba(0,22,18,0.64);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-weight: 1000;
- text-shadow: 0 0 12px rgba(97,255,190,0.75);
-}
-.hud-icon.danger { color: #ffd6df; border-color: rgba(255,77,109,0.5); text-shadow: 0 0 12px rgba(255,77,109,0.9); }
-.status-list dt {
- grid-area: label;
- font-size: 0.58rem;
- opacity: 0.68;
- white-space: nowrap;
-}
-.status-list dd {
- grid-area: value;
- margin: 0;
- font-size: 1.36rem;
-}
-.status-list meter { width: 58%; height: 8px; }
-.monitor-panel { min-height: 470px; }
-.monitor { min-height: 388px; }
-.monitor-caption {
- max-height: 42px;
- overflow: hidden;
- font-size: 0.82rem;
- line-height: 1.35;
-}
-.action-panel { min-height: 0; }
-.actions {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
- margin-bottom: 8px;
-}
-.actions button {
- min-height: 72px;
- display: block;
- padding: 6px;
- text-align: center;
- border-radius: 18px;
-}
-.action-keycap {
- display: grid;
- grid-template-rows: 44px auto;
- align-items: center;
- justify-items: center;
- gap: 4px;
- width: 100%;
-}
-.action-icon {
- width: 52px;
- min-width: 52px;
- height: 42px;
-}
-.action-label {
- max-width: 100%;
- font-size: 0.72rem;
- letter-spacing: 0.02em;
- opacity: 0.86;
-}
-#forceAnomaly {
- min-height: 42px;
- padding: 8px;
- font-size: 0.72rem;
- opacity: 0.72;
-}
-.log-panel { min-height: 0; }
-.logs {
- height: 148px;
- padding: 10px 12px 10px 28px;
- font-size: 0.76rem;
- line-height: 1.35;
-}
-.start-overlay {
- padding: 18px;
- background:
- linear-gradient(90deg, rgba(0,0,0,0.72), rgba(0,0,0,0.24) 38%, rgba(0,0,0,0.06) 78%),
- radial-gradient(circle at 66% 40%, rgba(255,77,109,0.10), transparent 20rem);
-}
-.start-card {
- width: min(390px, 88vw);
- padding: 22px;
- border-radius: 24px;
-}
-.start-card h2 {
- font-size: clamp(1.7rem, 5.2vw, 2.7rem);
- letter-spacing: -0.05em;
-}
-.start-copy {
- margin: 10px 0 12px;
- font-size: 0.92rem;
- line-height: 1.35;
- color: rgba(216,255,243,0.78);
-}
-.mission-strip,
-.risk-strip {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
- margin: 10px 0;
-}
-.mission-strip span,
-.risk-strip span {
- display: grid;
- place-items: center;
- min-height: 38px;
- border: 1px solid rgba(97,255,190,0.2);
- border-radius: 12px;
- background: rgba(0, 14, 15, 0.62);
- color: #d8fff3;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.72rem;
- font-weight: 950;
- letter-spacing: 0.08em;
-}
-.risk-strip span { color: #ffd6df; border-color: rgba(255,77,109,0.25); background: rgba(255,77,109,0.07); }
-.start-card button[data-role="primary-start"] {
- margin-top: 12px;
- min-height: 54px;
- font-size: 0.96rem;
-}
-@media (max-width: 860px) {
- .grid {
- grid-template-columns: 1fr;
- grid-template-areas:
- "monitor"
- "actions"
- "status"
- "logs";
- }
- .monitor-panel { min-height: 420px; }
- .action-dock { grid-template-columns: minmax(0, 1fr) 82px; }
- .actions { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .actions button { min-height: 66px; }
- .action-label { font-size: 0.62rem; }
- .status-list { grid-template-columns: repeat(5, minmax(0, 1fr)); }
- .status-list div { grid-template-columns: 1fr; grid-template-areas: "icon" "value"; justify-items: center; }
- .status-list dt { display: none; }
- .hud-icon { width: 34px; height: 26px; }
- .start-overlay { align-items: end; justify-items: center; }
- .start-card { width: min(560px, 94vw); }
-}
-
-
-/* Extreme HUD pass: remove onboarding-card feel. */
-.start-card {
- width: min(320px, 84vw);
- padding: 18px;
- border-radius: 18px;
- background:
- linear-gradient(90deg, rgba(97,255,190,0.08), transparent 46%),
- rgba(0, 9, 11, 0.70);
- box-shadow: 0 22px 80px rgba(0,0,0,0.54), inset 4px 0 0 rgba(97,255,190,0.68), 0 0 34px rgba(97,255,190,0.08);
-}
-.start-card .eyebrow {
- margin-bottom: 6px;
- font-size: 0.56rem;
- color: rgba(97,255,190,0.72);
-}
-.start-card h2 {
- font-size: clamp(1.4rem, 4.2vw, 2.25rem);
- text-transform: uppercase;
- color: #f1fff9;
-}
-.start-copy {
- margin: 6px 0 10px;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.78rem;
- color: rgba(216,255,243,0.64);
-}
-.mission-strip,
-.risk-strip {
- grid-template-columns: repeat(3, 1fr);
- gap: 6px;
- margin: 8px 0;
-}
-.mission-strip span,
-.risk-strip span {
- min-height: 30px;
- border-radius: 999px;
- font-size: 0.62rem;
- letter-spacing: 0.06em;
- background: rgba(0, 20, 18, 0.42);
-}
-.mission-strip span::before,
-.risk-strip span::before {
- content: "";
- width: 6px;
- height: 6px;
- margin-right: 5px;
- border-radius: 999px;
- background: currentColor;
- box-shadow: 0 0 10px currentColor;
-}
-.mission-strip span,
-.risk-strip span { display: flex; align-items: center; justify-content: center; }
-.start-card button[data-role="primary-start"] {
- margin-top: 10px;
- min-height: 48px;
- border-radius: 14px;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.9rem;
- letter-spacing: 0.16em;
- background: linear-gradient(135deg, rgba(255,209,102,0.96), rgba(97,255,190,0.92));
-}
-.log-panel {
- max-height: 208px;
-}
-.logs {
- height: 112px;
- border-radius: 14px;
- mask-image: linear-gradient(180deg, transparent, #000 18%, #000 88%, transparent);
-}
-.logs li:not(:last-child) { opacity: 0.58; }
-.logs li:last-child {
- background: linear-gradient(90deg, rgba(97,255,190,0.16), transparent 74%);
- box-shadow: inset 3px 0 0 rgba(97,255,190,0.7);
-}
-.monitor-hud span {
- font-size: 0.68rem;
- border-radius: 999px;
- padding: 6px 9px;
-}
-@media (max-width: 860px) {
- .start-card {
- width: min(360px, 92vw);
- justify-self: start;
- }
- .logs { height: 96px; }
-}
-
-
-/* Clarity pass: less fog, clearer hierarchy. */
-body::before { opacity: 0.18; }
-.console-shell { width: min(1240px, calc(100vw - 24px)); }
-h1.game-title { font-size: clamp(1.8rem, 4.2vw, 3.8rem); }
-.panel {
- background:
- linear-gradient(rgba(1, 10, 12, 0.90), rgba(1, 5, 7, 0.94)),
- var(--hud-glass-texture) center / cover no-repeat,
- linear-gradient(180deg, var(--panel-2), var(--panel));
- box-shadow: 0 18px 62px rgba(0,0,0,0.64), inset 0 1px 0 rgba(255,255,255,0.05);
-}
-.monitor {
- box-shadow: inset 0 0 0 1px rgba(97,255,190,0.16), 0 0 30px rgba(0,0,0,0.36);
-}
-.cctv-stage::before { opacity: 0.26; }
-.cctv-stage::after { opacity: 0.66; }
-.cctv-loop { opacity: 0; }
-.monitor[data-anomaly="active"] .cctv-loop { opacity: 0.62; }
-.status-list div {
- background:
- radial-gradient(circle at 16px 18px, rgba(97,255,190,0.10), transparent 42px),
- rgba(0, 10, 12, 0.76);
-}
-.actions button,
-.secondary-actions button,
-.more-actions-button {
- filter: saturate(0.86) brightness(0.88);
- box-shadow: 0 8px 18px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.18);
-}
-.actions button[data-action="openDoor"],
-.actions button[data-action="closeDoor"],
-.actions button[data-action="moveUp"],
-.actions button[data-action="moveDown"],
-.secondary-actions button[data-action="openDoor"],
-.secondary-actions button[data-action="closeDoor"],
-.secondary-actions button[data-action="moveUp"],
-.secondary-actions button[data-action="moveDown"] {
- filter: saturate(0.98) brightness(0.98);
-}
-.actions button[data-action="emergencyStop"],
-.secondary-actions button[data-action="emergencyStop"] {
- filter: saturate(1.18) brightness(1.06);
- box-shadow: 0 0 28px rgba(255,77,109,0.32), inset 0 1px 0 rgba(255,255,255,0.26);
-}
-.actions button[data-action="restartSystem"],
-.actions button[data-action="inspectLog"],
-.secondary-actions button[data-action="restartSystem"],
-.secondary-actions button[data-action="inspectLog"] {
- opacity: 0.82;
-}
-.start-overlay {
- background:
- linear-gradient(90deg, rgba(0,0,0,0.84), rgba(0,0,0,0.34) 36%, rgba(0,0,0,0.08) 78%),
- radial-gradient(circle at 64% 40%, rgba(255,77,109,0.08), transparent 20rem);
- backdrop-filter: none;
-}
-.start-card {
- background:
- linear-gradient(90deg, rgba(97,255,190,0.10), transparent 42%),
- rgba(0, 8, 10, 0.88);
-}
-.shift-card strong {
- color: #ffe28a;
- text-shadow: 0 0 18px rgba(255,209,102,0.42), 0 0 42px rgba(255,209,102,0.16);
-}
-
-
-/* Hide debug affordance from the main game control deck. */
-.action-panel { position: relative; }
-#forceAnomaly.diagnostic-trigger {
- position: absolute;
- right: 12px;
- top: 10px;
- z-index: 3;
- width: auto;
- min-height: 0;
- padding: 5px 8px;
- border-radius: 999px;
- border-color: rgba(255,209,102,0.18);
- color: rgba(255,238,194,0.52);
- background: rgba(255,209,102,0.04);
- box-shadow: none;
- font-size: 0.56rem;
- letter-spacing: 0.12em;
- opacity: 0.42;
-}
-#forceAnomaly.diagnostic-trigger:hover,
-#forceAnomaly.diagnostic-trigger:focus-visible {
- opacity: 0.9;
- color: #ffeec2;
- box-shadow: 0 0 18px rgba(255,209,102,0.14);
-}
-.actions button[data-action="restartSystem"] .action-label,
-.actions button[data-action="inspectLog"] .action-label,
-.actions button[data-action="unlockHiddenLog"] .action-label,
-.secondary-actions button[data-action="restartSystem"] .action-label,
-.secondary-actions button[data-action="inspectLog"] .action-label,
-.secondary-actions button[data-action="unlockHiddenLog"] .action-label {
- font-size: 0.68rem;
-}
-
-
-/* Monitor-dominant composition pass: CCTV is the play surface, chrome is support. */
-.game-title {
- font-size: clamp(1.4rem, 3.2vw, 3rem);
- max-width: 520px;
- letter-spacing: -0.055em;
-}
-.topbar {
- align-items: center;
- grid-template-columns: minmax(0, 1fr) 112px;
- padding: 7px 10px;
- margin-bottom: 8px;
- border-radius: 18px;
- opacity: 0.96;
-}
-.topbar::after {
- right: 132px;
- top: 9px;
- font-size: 0.52rem;
- opacity: 0.42;
-}
-.shift-card {
- min-width: 104px;
- padding: 7px 8px;
- border-radius: 14px;
-}
-.shift-card span { font-size: 0.52rem; }
-.shift-card strong { font-size: 2.2rem; }
-.grid {
- grid-template-columns: 0.52fr 1.48fr;
- grid-template-areas:
- "status monitor"
- "actions monitor"
- "logs monitor";
- gap: 8px;
- align-items: stretch;
-}
-.panel { padding: 10px; border-radius: 16px; }
-.panel-title {
- margin-bottom: 6px;
- font-size: 0.62rem;
- letter-spacing: 0.16em;
- opacity: 0.68;
-}
-.monitor-panel {
- min-height: 560px;
- padding: 12px;
- border-color: rgba(97,255,190,0.34);
- box-shadow: 0 28px 110px rgba(0,0,0,0.62), inset 0 0 0 1px rgba(97,255,190,0.08);
-}
-.monitor {
- min-height: 498px;
- padding: 12px;
-}
-.cctv-stage { min-height: 398px; }
-.monitor-caption {
- max-height: 30px;
- font-size: 0.7rem;
- opacity: 0.7;
-}
-.monitor-hud {
- margin-top: 7px;
- font-size: 0.66rem;
-}
-.status-panel,
-.action-panel,
-.log-panel { min-height: 0; }
-.status-list {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 5px;
-}
-.status-list div {
- min-height: 50px;
- padding: 5px 6px;
- grid-template-columns: 24px 1fr;
- border-radius: 10px;
- background: rgba(0, 8, 10, 0.48);
-}
-.hud-icon {
- width: 24px;
- height: 30px;
- border-radius: 8px;
- font-size: 0.72rem;
-}
-.status-list dt { font-size: 0.48rem; opacity: 0.5; }
-.status-list dd { font-size: 1rem; }
-.status-list meter { width: 50%; height: 6px; }
- .action-dock {
- grid-template-columns: minmax(0, 1fr) 74px;
- gap: 5px;
- }
- .actions {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 5px;
-}
-.actions button,
-.more-actions-button {
- min-height: 54px;
- border-radius: 13px;
- padding: 4px;
-}
-.action-keycap { grid-template-rows: 30px auto; gap: 2px; }
-.action-icon { width: 38px; min-width: 38px; height: 30px; font-size: 0.95rem; border-radius: 9px; }
-.action-label { font-size: 0.58rem; }
-.logs {
- height: 86px;
- padding: 8px 10px 8px 24px;
- font-size: 0.62rem;
- opacity: 0.78;
-}
-@media (max-width: 860px) {
- .grid {
- grid-template-columns: 1fr;
- grid-template-areas:
- "monitor"
- "actions"
- "status"
- "logs";
- }
- .monitor-panel { min-height: 430px; }
- .monitor { min-height: 350px; }
- .cctv-stage { min-height: 250px; }
-}
-
-
-/* Subdue start handoff so the CCTV remains the first read. */
-.start-overlay {
- background:
- linear-gradient(90deg, rgba(0,0,0,0.34), rgba(0,0,0,0.08) 32%, transparent 74%);
- pointer-events: none;
-}
-.start-card {
- opacity: 0.78;
- transform: scale(0.92);
- transform-origin: left center;
- filter: saturate(0.78) brightness(0.86);
- background: rgba(0, 9, 11, 0.52);
- box-shadow: 0 14px 46px rgba(0,0,0,0.38), inset 3px 0 0 rgba(97,255,190,0.38);
- pointer-events: auto;
-}
-.start-card h2 { font-size: clamp(1.05rem, 3vw, 1.62rem); }
-.start-copy { font-size: 0.66rem; opacity: 0.62; }
-.mission-strip span,
-.risk-strip span { opacity: 0.68; min-height: 24px; font-size: 0.52rem; }
-.start-card button[data-role="primary-start"] {
- min-height: 42px;
- filter: saturate(0.72) brightness(0.84);
- box-shadow: 0 0 18px rgba(97,255,190,0.12);
-}
-.start-card:focus-within,
-.start-card:hover {
- opacity: 0.94;
- filter: saturate(0.9) brightness(0.94);
-}
-
-
-/* Left rail hierarchy: make critical telemetry readable, demote collection counters. */
-.status-list div::after { display: none; }
-.status-list div[data-priority="critical"] {
- border-color: rgba(97,255,190,0.42);
- background:
- linear-gradient(90deg, rgba(97,255,190,0.10), rgba(0,8,10,0.46) 62%),
- rgba(0, 8, 10, 0.58);
-}
-.status-list div[data-priority="critical"] dd {
- color: #f1fff9;
- text-shadow: 0 0 14px rgba(97,255,190,0.42);
-}
-.status-list div[data-priority="danger"] {
- border-color: rgba(255,77,109,0.52);
- background:
- linear-gradient(90deg, rgba(255,77,109,0.14), rgba(0,8,10,0.50) 62%),
- rgba(0, 8, 10, 0.62);
-}
-.status-list div[data-priority="danger"] dd {
- color: #ffe4ea;
- text-shadow: 0 0 14px rgba(255,77,109,0.66);
-}
-.status-list div[data-priority="secondary"] {
- opacity: 0.62;
- border-color: rgba(97,255,190,0.12);
- background: rgba(0, 8, 10, 0.34);
-}
-.status-list div[data-priority="secondary"] .hud-icon {
- opacity: 0.66;
- box-shadow: none;
-}
-.status-list div[data-priority="secondary"] dd {
- color: rgba(216,255,243,0.72);
- text-shadow: none;
-}
-
-
-/* Compact authorization strip: keep start affordance without polluting telemetry rail. */
-.start-overlay {
- align-items: end;
- justify-items: start;
- padding: 0 0 12px 16px;
- background: linear-gradient(0deg, rgba(0,0,0,0.28), transparent 32%);
-}
-.start-card {
- width: min(360px, calc(100vw - 32px));
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- grid-template-areas:
- "title action"
- "copy action";
- align-items: center;
- column-gap: 10px;
- row-gap: 2px;
- padding: 9px 10px 9px 12px;
- border-radius: 999px;
- opacity: 0.72;
- transform: none;
- background: rgba(0, 9, 11, 0.48);
- box-shadow: 0 10px 34px rgba(0,0,0,0.36), inset 2px 0 0 rgba(97,255,190,0.32);
-}
-.start-card .eyebrow,
-.mission-strip,
-.risk-strip { display: none; }
-.start-card h2 {
- grid-area: title;
- font-size: 0.82rem;
- line-height: 1;
- letter-spacing: 0.02em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.start-copy {
- grid-area: copy;
- margin: 0;
- font-size: 0.56rem;
- line-height: 1.1;
- opacity: 0.54;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.start-card button[data-role="primary-start"] {
- grid-area: action;
- min-height: 34px;
- min-width: 104px;
- margin: 0;
- padding: 8px 12px;
- border-radius: 999px;
- font-size: 0.72rem;
- filter: saturate(0.62) brightness(0.82);
-}
-.start-card:hover,
-.start-card:focus-within { opacity: 0.92; }
-@media (max-width: 860px) {
- .start-overlay { padding: 0 10px 10px; justify-items: center; }
- .start-card { width: min(520px, 94vw); }
-}
-
-
-/* Non-overlapping start strip final placement. */
-.start-overlay {
- position: static;
- inset: auto;
- z-index: auto;
- display: flex;
- justify-content: end;
- align-items: center;
- margin-top: 8px;
- padding: 0;
- background: transparent;
- pointer-events: auto;
-}
-.start-card { margin-left: auto; }
-
-
-/* Success settlement: distinct from failure and never shows the revive CTA. */
-.failure-overlay[data-result="success"] .failure-card {
- border-color: rgba(97, 255, 190, 0.58);
- background: linear-gradient(180deg, rgba(3, 34, 25, 0.96), rgba(3, 12, 12, 0.99));
- box-shadow: 0 30px 90px rgba(0,0,0,0.62), 0 0 50px rgba(97,255,190,0.14);
-}
-.failure-overlay[data-result="success"] .failure-card h2 { color: var(--green); text-shadow: 0 0 22px rgba(97,255,190,0.34); }
-.failure-overlay[data-result="success"] .failure-actions { grid-template-columns: 1fr; }
-
-/* Mobile portrait final pass: keep CCTV first and keep the start strip reachable. */
-@media (max-width: 700px) and (orientation: portrait) {
- html, body { height: 100%; min-height: 100dvh; overflow: hidden; }
- .console-shell {
- width: 100%;
- height: 100dvh;
- min-height: 0;
- display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto;
- gap: 6px;
- padding: max(6px, env(safe-area-inset-top)) 6px max(10px, env(safe-area-inset-bottom));
- padding-bottom: max(10px, env(safe-area-inset-bottom));
- overflow: hidden;
- }
- .topbar {
- grid-template-columns: minmax(0, 1fr) 72px;
- margin-bottom: 6px;
- padding: 7px 8px;
- }
- .game-title { font-size: clamp(1.05rem, 6vw, 1.45rem); }
- .shift-card { padding: 5px 6px; }
- .shift-card span { font-size: 0.48rem; }
- .shift-card strong { font-size: 1.42rem; }
- .grid {
- height: 100%;
- min-height: 0;
- display: grid;
- grid-template-columns: 1fr;
- grid-template-rows: minmax(0, 1fr) auto auto minmax(58px, 0.28fr);
- grid-template-areas: "monitor" "actions" "status" "logs";
- gap: 6px;
- overflow: hidden;
- }
- .panel { min-height: 0; padding: 8px; border-radius: 14px; }
- .monitor-panel { min-height: 0; padding: 8px; }
- .monitor { min-height: 0; height: calc(100% - 20px); padding: 8px; gap: 6px; }
- .cctv-stage { min-height: 0; height: 100%; }
- .camera-frame { inset: 10px 76px 10px 10px; }
- .cctv-multiview { width: 64px; right: 8px; top: 38px; bottom: 8px; gap: 5px; }
- .monitor-caption { max-height: 2.6em; font-size: 0.68rem; line-height: 1.24; }
- .monitor-hud { margin-top: 6px; font-size: 0.52rem; gap: 5px; }
- .action-dock { grid-template-columns: minmax(0, 1fr) 68px; gap: 5px; }
- .action-guide { display: none; }
- .actions { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-bottom: 0; }
- .actions button,
- .more-actions-button { min-height: 48px; border-radius: 12px; padding: 4px; }
- .action-keycap { grid-template-rows: 25px auto; gap: 1px; }
- .action-icon { width: 32px; min-width: 32px; height: 25px; font-size: 0.8rem; }
- .action-label { font-size: 0.54rem; }
- .more-actions-button .action-icon { width: 32px; min-width: 32px; font-size: 0.46rem; letter-spacing: 0.06em; }
- .more-action-count { right: 3px; top: 3px; min-width: 15px; height: 15px; font-size: 0.5rem; }
- .secondary-actions-panel { width: 100%; padding: 14px; border-radius: 18px 18px 0 0; }
- .secondary-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
- .secondary-actions button { min-height: 48px; border-radius: 12px; padding: 5px; }
- #forceAnomaly { top: 6px; right: 6px; min-height: 18px; min-width: 36px; font-size: 0.5rem; }
- .status-list { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; }
- .status-list div { min-height: 42px; padding: 4px 3px; grid-template-columns: 1fr; grid-template-areas: "icon" "value"; justify-items: center; }
- .status-list dt { display: none; }
- .status-list dd { font-size: 0.82rem; line-height: 1; margin-top: 1px; }
- .hud-icon { width: 28px; height: 20px; font-size: 0.62rem; }
- .log-panel { max-height: none; overflow: hidden; }
- .logs { height: 100%; min-height: 0; padding: 7px 8px 7px 24px; font-size: 0.62rem; line-height: 1.2; overflow: auto; }
- .start-overlay {
- position: static;
- display: flex;
- justify-content: center;
- align-items: center;
- margin-top: 0;
- padding: 0;
- background: transparent;
- }
- .start-card {
- width: min(100%, 420px);
- min-height: 42px;
- transform: none;
- padding: 7px 8px 7px 10px;
- border-radius: 999px;
- }
- .start-card h2 { font-size: 0.76rem; }
- .start-copy { font-size: 0.5rem; }
- .start-card button[data-role="primary-start"] { min-height: 30px; min-width: 96px; font-size: 0.68rem; }
-}
-
-/* Short portrait fail-safe: preserve every panel by scrolling the shell, never by clipping it. */
-@media (max-width: 700px) and (orientation: portrait) and (max-height: 620px) {
- .console-shell {
- height: 100dvh;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- }
- .grid {
- height: auto;
- min-height: 0;
- grid-template-rows: minmax(120px, 32vh) auto auto minmax(44px, 12vh);
- overflow: visible;
- }
- .monitor,
- .cctv-stage { min-height: 96px; }
- .logs { min-height: 44px; max-height: 12vh; }
-}
-
-/* Imported first-game visual kit: labels stay in DOM; sprites skin the hardware surface only. */
-.actions button,
-.secondary-actions button,
-.more-actions-button {
- --action-button-sprite: var(--btn-scan-default);
- background:
- linear-gradient(180deg, rgba(255,255,255,0.18), rgba(0,0,0,0.16)),
- var(--action-button-sprite) center / 100% 100% no-repeat,
- linear-gradient(180deg, rgba(216,255,243,0.96), rgba(97,255,190,0.86) 54%, rgba(81,214,255,0.78));
-}
-.actions button[data-action="closeDoor"],
-.secondary-actions button[data-action="closeDoor"] {
- --action-button-sprite: var(--btn-close-default);
-}
-.actions button[data-action="moveUp"],
-.secondary-actions button[data-action="moveUp"],
-.actions button[data-recommended="true"],
-.secondary-actions button[data-recommended="true"] {
- --action-button-sprite: var(--btn-up-recommended);
-}
-.actions button[data-action="emergencyStop"],
-.secondary-actions button[data-action="emergencyStop"] {
- --action-button-sprite: var(--btn-stop-danger);
-}
-.actions button[data-action="inspectLog"],
-.actions button[data-action="unlockHiddenLog"],
-.secondary-actions button[data-action="inspectLog"],
-.secondary-actions button[data-action="unlockHiddenLog"] {
- --action-button-sprite: var(--btn-log-secondary);
-}
-.more-actions-button {
- --action-button-sprite: var(--btn-more-secondary);
-}
-.actions button:active,
-.secondary-actions button:active,
-.more-actions-button:active {
- --action-button-sprite: var(--btn-pressed);
-}
-.actions button:disabled,
-.secondary-actions button:disabled,
-.more-actions-button:disabled {
- --action-button-sprite: var(--btn-disabled);
-}
-.cctv-stage {
- background:
- linear-gradient(rgba(1, 14, 14, 0.18), rgba(0, 4, 5, 0.70)),
- var(--visual-kit-cctv-frame) center / cover no-repeat,
- var(--cctv-feed) center / cover no-repeat,
- radial-gradient(circle at 50% 44%, rgba(97,255,190,0.14), transparent 34%),
- linear-gradient(180deg, rgba(5, 22, 24, 0.95), rgba(0, 5, 6, 0.98));
-}
-.cctv-stage::before {
- background:
- linear-gradient(rgba(3, 20, 16, 0.08), rgba(0,0,0,0.16)),
- var(--visual-kit-scanlines) center / cover no-repeat,
- var(--cctv-noise-texture) center / cover no-repeat,
- radial-gradient(circle at 50% 50%, transparent 0 56%, rgba(0,0,0,0.54) 100%);
-}
-.cctv-stage::after {
- background:
- var(--visual-kit-scan-sweep) center / cover no-repeat,
- linear-gradient(90deg, transparent, rgba(81,214,255,0.06), transparent);
-}
-.monitor[data-cctv-state="00_idle_closed"] .cctv-stage { --cctv-feed: var(--cctv-state-00-idle-closed); }
-.monitor[data-cctv-state="01_door_open"] .cctv-stage { --cctv-feed: var(--cctv-state-01-door-open); }
-.monitor[data-cctv-state="02_door_opening"] .cctv-stage { --cctv-feed: var(--cctv-state-02-door-opening); }
-.monitor[data-cctv-state="03_door_closing"] .cctv-stage { --cctv-feed: var(--cctv-state-03-door-closing); }
-.monitor[data-cctv-state="04_moving_up"] .cctv-stage { --cctv-feed: var(--cctv-state-04-moving-up); }
-.monitor[data-cctv-state="05_moving_down"] .cctv-stage { --cctv-feed: var(--cctv-state-05-moving-down); }
-.monitor[data-cctv-state="06_power_low"] .cctv-stage { --cctv-feed: var(--cctv-state-06-power-low); }
-.monitor[data-cctv-state="07_power_outage"] .cctv-stage { --cctv-feed: var(--cctv-state-07-power-outage); }
-.monitor[data-cctv-state="08_emergency_stop"] .cctv-stage { --cctv-feed: var(--cctv-state-08-emergency-stop); }
-.monitor[data-cctv-state="09_door_jammed"] .cctv-stage { --cctv-feed: var(--cctv-state-09-door-jammed); }
-.monitor[data-cctv-state="10_signal_lost"] .cctv-stage { --cctv-feed: var(--cctv-state-10-signal-lost); }
-.monitor[data-cctv-state="11_camera_glitch"] .cctv-stage { --cctv-feed: var(--cctv-state-11-camera-glitch); }
-.monitor[data-cctv-state="12_scan_active"] .cctv-stage { --cctv-feed: var(--cctv-state-12-scan-active); }
-.monitor[data-cctv-state="13_entity_near"] .cctv-stage { --cctv-feed: var(--cctv-state-13-entity-near); }
-.monitor[data-cctv-state="14_shadow_inside"] .cctv-stage { --cctv-feed: var(--cctv-state-14-shadow-inside); }
-.monitor[data-cctv-state="15_anomaly_wandering"] .cctv-stage { --cctv-feed: var(--cctv-state-15-anomaly-wandering); }
-.monitor[data-cctv-state="16_wrong_floor"] .cctv-stage { --cctv-feed: var(--cctv-state-16-wrong-floor); }
-.monitor[data-cctv-state="17_loop_corridor"] .cctv-stage { --cctv-feed: var(--cctv-state-17-loop-corridor); }
-.monitor[data-cctv-state="18_locked"] .cctv-stage { --cctv-feed: var(--cctv-state-18-locked); }
-.monitor[data-cctv-state="19_stabilized"] .cctv-stage { --cctv-feed: var(--cctv-state-19-stabilized); }
-.monitor[data-cctv-state="20_threat_high"] .cctv-stage { --cctv-feed: var(--cctv-state-20-threat-high); }
-.monitor[data-cctv-state="21_maintenance_mode"] .cctv-stage { --cctv-feed: var(--cctv-state-21-maintenance-mode); }
-.monitor[data-cctv-state="22_system_reboot"] .cctv-stage { --cctv-feed: var(--cctv-state-22-system-reboot); }
-.monitor[data-cctv-state="23_cooldown_safe"] .cctv-stage { --cctv-feed: var(--cctv-state-23-cooldown-safe); }
-.monitor[data-glitch="true"] .cctv-stage::after {
- background:
- var(--visual-kit-glitch-blocks) center / cover no-repeat,
- var(--visual-kit-scan-sweep) center / cover no-repeat,
- linear-gradient(90deg, transparent, rgba(255,77,109,0.16), transparent);
-}
-.console-shell[data-tone="critical"] .monitor[data-anomaly="active"] .cctv-stage,
-.console-shell[data-tone="danger"] .monitor[data-anomaly="active"] .cctv-stage {
- background:
- linear-gradient(rgba(45, 0, 9, 0.18), rgba(0, 4, 5, 0.74)),
- var(--visual-kit-red-alert-frame) center / cover no-repeat,
- var(--cctv-feed) center / cover no-repeat,
- radial-gradient(circle at 50% 44%, rgba(255,77,109,0.18), transparent 34%),
- linear-gradient(180deg, rgba(5, 22, 24, 0.95), rgba(0, 5, 6, 0.98));
-}
-
-@media (max-width: 700px) and (orientation: portrait) {
- :root {
- --cctv-state-00-idle-closed: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/00_idle_closed_mobile.png");
- --cctv-state-01-door-open: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/01_door_open_mobile.png");
- --cctv-state-02-door-opening: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/02_door_opening_mobile.png");
- --cctv-state-03-door-closing: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/03_door_closing_mobile.png");
- --cctv-state-04-moving-up: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/04_moving_up_mobile.png");
- --cctv-state-05-moving-down: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/05_moving_down_mobile.png");
- --cctv-state-06-power-low: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/06_power_low_mobile.png");
- --cctv-state-07-power-outage: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/07_power_outage_mobile.png");
- --cctv-state-08-emergency-stop: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/08_emergency_stop_mobile.png");
- --cctv-state-09-door-jammed: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/09_door_jammed_mobile.png");
- --cctv-state-10-signal-lost: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/10_signal_lost_mobile.png");
- --cctv-state-11-camera-glitch: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/11_camera_glitch_mobile.png");
- --cctv-state-12-scan-active: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/12_scan_active_mobile.png");
- --cctv-state-13-entity-near: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/13_entity_near_mobile.png");
- --cctv-state-14-shadow-inside: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/14_shadow_inside_mobile.png");
- --cctv-state-15-anomaly-wandering: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/15_anomaly_wandering_mobile.png");
- --cctv-state-16-wrong-floor: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/16_wrong_floor_mobile.png");
- --cctv-state-17-loop-corridor: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/17_loop_corridor_mobile.png");
- --cctv-state-18-locked: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/18_locked_mobile.png");
- --cctv-state-19-stabilized: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/19_stabilized_mobile.png");
- --cctv-state-20-threat-high: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/20_threat_high_mobile.png");
- --cctv-state-21-maintenance-mode: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/21_maintenance_mode_mobile.png");
- --cctv-state-22-system-reboot: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/22_system_reboot_mobile.png");
- --cctv-state-23-cooldown-safe: url("assets/abnormal_elevator_visual_assets/mobile_cctv_states/23_cooldown_safe_mobile.png");
- }
-}
-
-/* UI V3 NIGHT RELAY — Monitor primary, Operate secondary. */
-:root {
- --bg: #07090a;
- --panel: #0c0f10;
- --panel-2: #15191b;
- --panel-3: #090b0c;
- --line: rgba(195, 200, 190, 0.22);
- --line-hot: rgba(225, 168, 75, 0.68);
- --text: #e7e2d5;
- --muted: #8e928c;
- --green: #79d6a3;
- --amber: #e1a84b;
- --red: #e75c4f;
- --cyan: #84b9b0;
- --shadow: rgba(0, 0, 0, 0.72);
-}
-
-html,
-body { background: #07090a; }
-body {
- min-height: 100dvh;
- overflow: hidden;
- font-family: Bahnschrift, "Microsoft YaHei UI", system-ui, sans-serif;
- background:
- linear-gradient(rgba(255,255,255,0.012) 1px, transparent 1px),
- radial-gradient(circle at 52% -20%, rgba(225,168,75,0.07), transparent 42rem),
- #07090a;
- background-size: 24px 24px, auto, auto;
-}
-body::before {
- z-index: 90;
- opacity: 0.18;
- mix-blend-mode: soft-light;
- background:
- repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,255,255,0.025) 3px 4px),
- radial-gradient(circle at 50% 50%, transparent 0 70%, rgba(0,0,0,0.66) 100%);
-}
-
-.console-shell {
- width: min(1540px, calc(100vw - 16px));
- height: 100dvh;
- min-height: 0;
- margin: 0 auto;
- padding: 8px 0;
- display: grid;
- grid-template-rows: 68px minmax(0, 1fr);
- gap: 8px;
- overflow: hidden;
-}
-
-.topbar {
- min-height: 0;
- margin: 0;
- padding: 10px 14px 9px;
- grid-template-columns: minmax(0, 1fr) 124px;
- align-items: center;
- border: 1px solid rgba(195,200,190,0.22);
- border-radius: 6px;
- background:
- linear-gradient(180deg, rgba(255,255,255,0.055), transparent 24%),
- linear-gradient(90deg, #141819, #0a0c0d 72%);
- box-shadow: inset 0 0 0 2px rgba(0,0,0,0.72), 0 8px 22px rgba(0,0,0,0.38);
-}
-.topbar::before {
- content: "";
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 4px;
- background: var(--green);
- box-shadow: 0 0 10px rgba(121,214,163,0.35);
-}
-.topbar::after {
- content: "NIGHT RELAY / LIFT-03 / OPERATOR LINK";
- right: 152px;
- top: 14px;
- color: rgba(231,226,213,0.28);
- font-size: 0.58rem;
- letter-spacing: 0.18em;
-}
-.eyebrow {
- margin: 0 0 2px;
- color: var(--green);
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.58rem;
- font-weight: 700;
- letter-spacing: 0.18em;
-}
-h1,
-.game-title {
- max-width: none;
- font-family: "Bahnschrift Condensed", "Arial Narrow", "Microsoft YaHei UI", sans-serif;
- font-size: clamp(1.7rem, 3vw, 2.65rem);
- font-weight: 700;
- line-height: 0.95;
- letter-spacing: -0.045em;
- color: #eee9dc;
- text-shadow: 1px 0 rgba(231,92,79,0.32), -1px 0 rgba(132,185,176,0.2);
-}
-.shift-card {
- min-width: 0;
- padding: 6px 10px;
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 8px;
- border: 1px solid rgba(225,168,75,0.42);
- border-radius: 4px;
- background: #080a0a;
- box-shadow: inset 0 0 0 2px #111516;
- text-align: left;
-}
-.shift-card span {
- font-size: 0.52rem;
- letter-spacing: 0.08em;
- line-height: 1.15;
-}
-.shift-card strong {
- margin: 0;
- font-size: 2rem;
- color: #f1c26d;
- text-shadow: 0 0 12px rgba(225,168,75,0.24);
-}
-
-.grid {
- min-height: 0;
- height: 100%;
- display: grid;
- grid-template-columns: 236px minmax(0, 1fr) 252px;
- grid-template-rows: minmax(0, 1fr) 176px;
- grid-template-areas:
- "status monitor actions"
- "logs monitor actions";
- gap: 8px;
- overflow: hidden;
-}
-.panel {
- min-width: 0;
- min-height: 0;
- padding: 10px;
- border: 1px solid rgba(195,200,190,0.20);
- border-radius: 6px;
- background:
- linear-gradient(180deg, rgba(255,255,255,0.035), transparent 16%),
- linear-gradient(135deg, #15191a, #090b0c 62%);
- box-shadow: inset 0 0 0 2px rgba(0,0,0,0.66), 0 10px 28px rgba(0,0,0,0.3);
-}
-.panel::before {
- inset: 4px;
- border-color: rgba(255,255,255,0.035);
- border-radius: 3px;
-}
-.panel-title {
- min-height: 18px;
- margin-bottom: 8px;
- gap: 7px;
- color: #bbb9af;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 0.66rem;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-.panel-title::before {
- width: 6px;
- height: 6px;
- border-radius: 1px;
- background: var(--green);
- box-shadow: 0 0 8px rgba(121,214,163,0.45);
-}
-
-.status-panel { overflow: hidden; }
-.status-list {
- height: calc(100% - 26px);
- grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-auto-rows: minmax(54px, 1fr);
- gap: 5px;
-}
-.status-list div,
-.status-list div[data-priority="critical"],
-.status-list div[data-priority="danger"],
-.status-list div[data-priority="secondary"] {
- min-width: 0;
- min-height: 0;
- padding: 7px 7px 6px;
- grid-template-columns: 26px minmax(0, 1fr);
- border: 1px solid rgba(195,200,190,0.15);
- border-radius: 3px;
- background: linear-gradient(180deg, #111516, #090b0c);
- opacity: 1;
-}
-.status-list div[data-priority="critical"] { border-color: rgba(121,214,163,0.34); }
-.status-list div[data-priority="danger"] { border-color: rgba(231,92,79,0.5); }
-.status-list div[data-priority="secondary"] { display: none; }
-.status-list dt {
- color: #777c78;
- font-size: 0.5rem;
- letter-spacing: 0.06em;
-}
-.status-list dd,
-.status-list div[data-priority="critical"] dd,
-.status-list div[data-priority="danger"] dd {
- margin-top: 3px;
- color: #e6e1d5;
- font-family: "Cascadia Mono", Consolas, monospace;
- font-size: 1rem;
- text-shadow: none;
-}
-.hud-icon {
- width: 24px;
- height: 30px;
- border: 1px solid rgba(121,214,163,0.32);
- border-radius: 2px;
- background: #080b0b;
- box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
- color: #9de0ba;
- font-size: 0.66rem;
-}
-meter {
- width: calc(100% - 28px);
- height: 6px;
- margin-right: 4px;
- filter: none;
-}
-
-.monitor-panel {
- min-height: 0;
- padding: 10px;
- background: linear-gradient(180deg, #111516, #070909);
-}
-.monitor {
- height: calc(100% - 44px);
- min-height: 0;
- padding: 5px;
- gap: 5px;
- border: 1px solid rgba(121,214,163,0.28);
- border-radius: 3px;
- background: #030605;
- box-shadow: inset 0 0 0 2px #101615, inset 0 0 60px rgba(0,0,0,0.72);
- text-shadow: none;
-}
-.monitor::before {
- left: 10px;
- top: 8px;
- font-size: 0.55rem;
- color: rgba(225,168,75,0.88);
-}
-.monitor::before,
-.monitor::after { display: none; }
-.cctv-stage {
- min-height: 0;
- border: 0;
- border-radius: 2px;
- box-shadow: inset 0 0 50px rgba(0,0,0,0.82);
- filter: contrast(1.06) saturate(0.72);
-}
-.camera-frame {
- inset: 24px 112px 16px 12px;
- border-radius: 1px;
- border-color: rgba(121,214,163,0.22);
-}
-.cctv-multiview {
- width: 92px;
- right: 10px;
- top: 34px;
- bottom: 10px;
- gap: 6px;
-}
-.cctv-multiview div {
- border-radius: 2px;
- background-color: rgba(0,4,4,0.86);
-}
-.monitor-caption {
- min-height: 26px;
- max-height: 40px;
- padding: 6px 9px;
- border-radius: 2px;
- color: #c8c6bd;
- background: #080b0b;
- font-size: 0.72rem;
- line-height: 1.28;
-}
-.monitor-hud {
- left: 20px;
- right: 20px;
- bottom: 15px;
- gap: 5px;
- font-size: 0.55rem;
- letter-spacing: 0.08em;
-}
-.monitor-hud span {
- padding: 5px 8px;
- border-radius: 2px;
- background: rgba(4,8,8,0.88);
- box-shadow: none;
-}
-.monitor-hud .operator-cue {
- color: #e7c786;
- background: rgba(16,12,6,0.9);
-}
-
-.action-panel {
- overflow: hidden;
- background:
- linear-gradient(180deg, rgba(255,255,255,0.035), transparent 14%),
- linear-gradient(135deg, #181c1d, #090b0c 68%);
-}
-.action-dock { display: block; }
-.actions {
- display: grid;
- grid-template-columns: 1fr;
- gap: 8px;
- margin: 0 0 8px;
-}
-.actions button,
-.secondary-actions button,
-.more-actions-button {
- --action-button-sprite: none;
- min-width: 0;
- min-height: 56px;
- padding: 7px;
- border: 1px solid #4b504e;
- border-radius: 5px;
- background:
- linear-gradient(180deg, rgba(255,255,255,0.12), transparent 18%),
- linear-gradient(180deg, #2a2f30, #141718 58%, #0a0c0d);
- box-shadow:
- inset 0 0 0 2px #080a0a,
- inset 0 1px 0 rgba(255,255,255,0.12),
- 0 3px 0 #050606;
- color: #d8d5ca;
- text-shadow: none;
-}
-.actions button:hover,
-.secondary-actions button:hover,
-.more-actions-button:hover {
- border-color: #777c78;
- filter: brightness(1.08);
-}
-.actions button:focus-visible,
-.secondary-actions button:focus-visible,
-.more-actions-button:focus-visible {
- outline: 2px solid #f0bf64;
- outline-offset: 2px;
-}
-.actions button:active,
-.secondary-actions button:active,
-.more-actions-button:active {
- --action-button-sprite: none;
- transform: translateY(2px);
- box-shadow: inset 0 0 0 2px #050606, inset 0 4px 10px rgba(0,0,0,0.72);
-}
-.actions button:disabled,
-.secondary-actions button:disabled,
-.more-actions-button:disabled {
- --action-button-sprite: none;
- opacity: 0.36;
- filter: grayscale(1);
-}
-.actions button[data-recommended="true"],
-.secondary-actions button[data-recommended="true"] {
- --action-button-sprite: none;
- border-color: var(--amber);
- box-shadow: inset 0 0 0 2px #080a0a, 0 0 0 1px rgba(225,168,75,0.28), 0 3px 0 #050606;
- animation: v3Recommended 1.6s ease-in-out infinite;
-}
-.actions button[data-action="emergencyStop"],
-.secondary-actions button[data-action="emergencyStop"] {
- --action-button-sprite: none;
- border-color: rgba(231,92,79,0.72);
- background: linear-gradient(180deg, #492420, #241110 60%, #100909);
-}
-.action-keycap {
- min-width: 0;
- grid-template-columns: 42px minmax(0, 1fr);
- grid-template-rows: 1fr;
- align-items: center;
- justify-items: stretch;
- gap: 8px;
-}
-.action-icon {
- width: 38px;
- min-width: 38px;
- height: 36px;
- border-radius: 3px;
- border: 1px solid rgba(121,214,163,0.34);
- background: #060909;
- color: #a7dec0;
- box-shadow: inset 0 0 8px rgba(121,214,163,0.08);
-}
-.action-label {
- font-size: 0.78rem;
- text-align: left;
- letter-spacing: 0.06em;
-}
-.more-actions-button { width: 100%; }
-.more-actions-button .action-keycap {
- grid-template-columns: 42px minmax(0, 1fr);
- grid-template-rows: 1fr;
- justify-items: stretch;
-}
-.more-actions-button .action-icon { width: 38px; min-width: 38px; }
-.more-action-count {
- right: 6px;
- top: 6px;
- background: #d3a54f;
-}
-.action-guide {
- grid-template-columns: 1fr;
- gap: 6px;
- margin-top: 10px;
-}
-.directive-card {
- min-height: 58px;
- padding: 8px 9px;
- border-radius: 3px;
- background: #0a0d0d;
-}
-.directive-card strong { color: #d5b675; font-size: 0.72rem; }
-.directive-card span { font-size: 0.61rem; color: #8d928d; }
-
-.log-panel { overflow: hidden; }
-.logs {
- height: calc(100% - 25px);
- min-height: 0;
- padding: 7px 7px 7px 24px;
- border: 0;
- border-radius: 2px;
- background: #070909;
- color: #aeb4ad;
- font-size: 0.62rem;
- line-height: 1.38;
-}
-
-.start-overlay {
- position: fixed;
- inset: 0;
- z-index: 70;
- display: grid;
- place-items: center;
- justify-content: center;
- align-content: center;
- margin: 0;
- padding: 16px;
- background: rgba(2,3,3,0.72);
- backdrop-filter: blur(3px);
- pointer-events: auto;
-}
-.start-card {
- width: min(430px, calc(100vw - 32px));
- margin: 0;
- padding: 22px;
- display: block;
- border: 1px solid rgba(225,168,75,0.54);
- border-radius: 8px;
- opacity: 1;
- transform: none;
- filter: none;
- background:
- linear-gradient(180deg, rgba(255,255,255,0.055), transparent 18%),
- #101314;
- box-shadow: inset 0 0 0 3px #070909, 0 28px 80px rgba(0,0,0,0.72);
-}
-.start-card .eyebrow { display: block; }
-.start-card h2 {
- margin-top: 6px;
- font-size: 1.5rem;
- line-height: 1.06;
- white-space: normal;
-}
-.start-copy {
- margin: 8px 0 14px;
- font-size: 0.82rem;
- line-height: 1.45;
- opacity: 0.78;
- white-space: normal;
-}
-.mission-strip,
-.risk-strip {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 5px;
- margin-top: 6px;
-}
-.mission-strip span,
-.risk-strip span {
- min-height: 30px;
- display: grid;
- place-items: center;
- border-radius: 2px;
- font-size: 0.58rem;
-}
-.start-card button[data-role="primary-start"] {
- width: 100%;
- min-height: 48px;
- margin-top: 14px;
- border-radius: 4px;
- border-color: rgba(121,214,163,0.66);
- background: linear-gradient(180deg, #244638, #12261f);
- color: #e8eee8;
- filter: none;
- font-size: 0.84rem;
- box-shadow: inset 0 0 0 2px #080b0a, 0 4px 0 #050706;
-}
-.failure-card,
-.archive-card,
-.ending-card {
- width: min(580px, 100%);
- padding: 24px;
- border-radius: 7px;
- background: #111415;
- box-shadow: inset 0 0 0 3px #070909, 0 28px 80px rgba(0,0,0,0.72);
-}
-.failure-card h2 {
- font-size: clamp(1.9rem, 6vw, 2.6rem);
- color: var(--red);
- text-shadow: none;
-}
-.failure-metrics span,
-.post-run-summary span,
-.archive-stats span,
-.anomaly-entry {
- border-radius: 3px;
- background: #171a1c;
-}
-.post-run-summary span,
-.archive-stats span,
-.anomaly-entry { border-color: rgba(195,200,190,0.2); }
-.post-run-summary b,
-.archive-stats b { color: #8e928c; }
-.post-run-summary strong,
-.archive-stats strong,
-.anomaly-entry strong { color: #d8d5ca; }
-.archive-card { border-color: rgba(121,214,163,0.46); }
-.archive-card h2 { color: var(--amber); text-shadow: none; }
-.anomaly-entry span { color: #aeb4ad; }
-.failure-actions button {
- min-height: 48px;
- border-radius: 4px;
- border: 1px solid rgba(195,200,190,0.3);
- background: linear-gradient(180deg, #292e2f, #111415);
- color: #e7e2d5;
- box-shadow: inset 0 0 0 2px #080a0a, 0 3px 0 #050606;
-}
-.failure-actions button:first-child {
- border-color: rgba(121,214,163,0.64);
- background: linear-gradient(180deg, #244638, #12261f);
-}
-.secondary-actions-sheet { align-items: center; }
-.secondary-actions-backdrop { background: rgba(2,3,3,0.76); backdrop-filter: blur(2px); }
-.secondary-actions-panel {
- width: min(520px, calc(100vw - 24px));
- margin: 0 auto;
- padding: 16px;
- border: 1px solid rgba(121,214,163,0.36);
- border-radius: 7px;
- background: #111415;
- box-shadow: inset 0 0 0 3px #070909, 0 28px 80px rgba(0,0,0,0.72);
-}
-.secondary-actions-head { margin-bottom: 12px; }
-.secondary-actions-head h2 { font-size: 1.1rem; }
-.sheet-close {
- min-width: 94px;
- min-height: 40px;
- border-radius: 4px;
- border-color: rgba(195,200,190,0.28);
- background: linear-gradient(180deg, #282d2e, #111415);
- color: #d8d5ca;
- box-shadow: inset 0 0 0 2px #080a0a;
-}
-@keyframes v3Recommended {
- 0%, 100% { border-color: rgba(225,168,75,0.62); }
- 50% { border-color: #f0bd5b; box-shadow: inset 0 0 0 2px #080a0a, 0 0 12px rgba(225,168,75,0.18), 0 3px 0 #050606; }
-}
-
-@media (max-width: 960px) and (min-width: 701px) {
- .grid {
- grid-template-columns: 220px minmax(0, 1fr);
- grid-template-rows: minmax(220px, auto) minmax(180px, 1fr) 150px;
- grid-template-areas:
- "status monitor"
- "actions monitor"
- "logs monitor";
- }
- .action-dock { display: grid; grid-template-columns: minmax(0, 1fr) 72px; }
- .actions { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
- .action-guide { display: none; }
- .camera-frame { right: 82px; }
-}
-
-@media (max-width: 700px) and (orientation: portrait) {
- html,
- body { height: 100%; min-height: 100dvh; overflow: hidden; }
- body::before { opacity: 0.12; }
- .console-shell {
- width: 100%;
- height: 100dvh;
- min-height: 0;
- padding: max(5px, env(safe-area-inset-top)) 5px max(6px, env(safe-area-inset-bottom));
- grid-template-rows: 54px minmax(0, 1fr);
- gap: 5px;
- overflow: hidden;
- }
- .topbar {
- min-height: 0;
- padding: 7px 8px 6px 10px;
- grid-template-columns: minmax(0, 1fr) 78px;
- border-radius: 4px;
- }
- .topbar::after,
- .topbar .eyebrow { display: none; }
- .game-title { font-size: clamp(1.24rem, 6.4vw, 1.65rem); }
- .shift-card { padding: 4px 6px; }
- .shift-card span { display: none; }
- .shift-card strong { font-size: 1.55rem; text-align: center; }
- .grid {
- height: 100%;
- min-height: 0;
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(220px, 1fr) 82px 112px minmax(62px, 0.26fr);
- grid-template-areas: "monitor" "actions" "status" "logs";
- gap: 5px;
- overflow: hidden;
- }
- .panel { padding: 6px; border-radius: 4px; }
- .panel::before { inset: 3px; }
- .panel-title { min-height: 14px; margin-bottom: 4px; font-size: 0.56rem; }
- .monitor-panel,
- .monitor { min-height: 0; }
- .monitor-panel { padding: 6px; }
- .monitor { height: calc(100% - 18px); padding: 3px; gap: 3px; }
- .cctv-stage { min-height: 0; height: 100%; }
- .camera-frame { inset: 18px 7px 7px; }
- .cctv-multiview { display: none; }
- .monitor-caption {
- min-height: 21px;
- max-height: 27px;
- padding: 4px 6px;
- font-size: 0.58rem;
- line-height: 1.16;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .monitor-hud {
- left: 10px;
- right: 10px;
- bottom: 7px;
- font-size: 0.47rem;
- }
- .monitor-hud span { padding: 3px 5px; }
- .monitor-hud #monitorThreat { display: none; }
- .monitor-hud .operator-cue { min-width: 0; }
- .action-panel { padding: 6px; }
- .action-panel .panel-title { margin-bottom: 3px; }
- .action-dock {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 4px;
- }
- .actions { display: contents; }
- .actions button,
- .more-actions-button {
- width: 100%;
- min-width: 0;
- min-height: 56px;
- padding: 4px 3px;
- border-radius: 4px;
- }
- .action-keycap,
- .more-actions-button .action-keycap {
- display: grid;
- grid-template-columns: 1fr;
- grid-template-rows: 28px auto;
- justify-items: center;
- gap: 2px;
- }
- .action-icon,
- .more-actions-button .action-icon {
- width: 32px;
- min-width: 32px;
- height: 27px;
- font-size: 0.68rem;
- }
- .action-label { font-size: 0.56rem; text-align: center; }
- .more-action-count { right: 2px; top: 2px; }
- .action-guide { display: none; }
- .status-panel { padding: 6px; }
- .status-list {
- height: calc(100% - 18px);
- grid-template-columns: repeat(4, minmax(0, 1fr));
- grid-template-rows: repeat(2, minmax(0, 1fr));
- grid-auto-rows: minmax(0, 1fr);
- gap: 3px;
- }
- .status-list div,
- .status-list div[data-priority="critical"],
- .status-list div[data-priority="danger"] {
- min-height: 0;
- padding: 3px 2px;
- grid-template-columns: 1fr;
- grid-template-areas: "icon" "value";
- justify-items: center;
- }
- .status-list div[data-priority="secondary"] { display: none; }
- .status-list dt { display: none; }
- .status-list dd { margin: 1px 0 0; font-size: 0.72rem; }
- .hud-icon { width: 26px; height: 18px; font-size: 0.56rem; }
- .status-list meter { display: none; }
- .log-panel { min-height: 0; padding: 6px; }
- .logs { height: calc(100% - 18px); min-height: 0; padding: 5px 5px 5px 20px; font-size: 0.54rem; }
- .secondary-actions-sheet { align-items: end; }
- .secondary-actions-panel {
- width: 100%;
- max-height: 82dvh;
- margin: 0;
- border-radius: 8px 8px 4px 4px;
- overflow-y: auto;
- }
- .start-overlay {
- position: fixed;
- align-items: end;
- align-content: end;
- justify-content: stretch;
- justify-items: stretch;
- padding: 10px 6px max(10px, env(safe-area-inset-bottom));
- background: rgba(2,3,3,0.68);
- }
- .start-card {
- width: auto;
- max-width: none;
- min-width: 0;
- justify-self: stretch;
- margin: 0;
- max-height: min(78dvh, 520px);
- padding: 16px;
- border-radius: 8px 8px 4px 4px;
- overflow-y: auto;
- }
- .start-card h2 { font-size: 1.18rem; }
- .start-copy { margin: 6px 0 10px; font-size: 0.72rem; }
- .mission-strip span,
- .risk-strip span { min-height: 26px; font-size: 0.52rem; }
- .start-card button[data-role="primary-start"] { min-height: 48px; }
- .failure-overlay,
- .archive-overlay,
- .fake-ending { align-items: end; padding: 8px 5px max(8px, env(safe-area-inset-bottom)); }
- .failure-card,
- .archive-card,
- .ending-card { width: 100%; max-height: 88dvh; border-radius: 8px 8px 4px 4px; overflow-y: auto; }
-}
-
-@media (max-width: 700px) and (orientation: portrait) and (max-height: 620px) {
- html,
- body { overflow: hidden; }
- .console-shell {
- height: 100dvh;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- }
- .grid {
- height: auto;
- min-height: 510px;
- grid-template-rows: 250px 82px 112px 62px;
- overflow: visible;
- }
- .monitor,
- .cctv-stage { min-height: 0; }
- .logs { min-height: 38px; max-height: 44px; }
- .start-card { max-height: calc(100dvh - 20px); }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .actions button[data-recommended="true"],
- .secondary-actions button[data-recommended="true"],
- .scanline,
- .cctv-stage::after { animation: none !important; }
-}
diff --git a/assets/generated/cctv-basement-lift-real.png b/assets/generated/cctv-basement-lift-real.png
deleted file mode 100644
index bab1ec0..0000000
Binary files a/assets/generated/cctv-basement-lift-real.png and /dev/null differ
diff --git a/assets/generated/monitor-cctv-elevator.png b/assets/generated/monitor-cctv-elevator.png
deleted file mode 100644
index 43bf5c3..0000000
Binary files a/assets/generated/monitor-cctv-elevator.png and /dev/null differ
diff --git a/assets/generated/monitor-cctv-real-basement-lift.png b/assets/generated/monitor-cctv-real-basement-lift.png
deleted file mode 100644
index 194e6a9..0000000
Binary files a/assets/generated/monitor-cctv-real-basement-lift.png and /dev/null differ
diff --git a/assets/generated/monitor-cctv-real-hospital-corridor.png b/assets/generated/monitor-cctv-real-hospital-corridor.png
deleted file mode 100644
index 7fd47c1..0000000
Binary files a/assets/generated/monitor-cctv-real-hospital-corridor.png and /dev/null differ
diff --git a/assets/generated/ui-reference-failure-screen.png b/assets/generated/ui-reference-failure-screen.png
deleted file mode 100644
index 079bdfa..0000000
Binary files a/assets/generated/ui-reference-failure-screen.png and /dev/null differ
diff --git a/assets/generated/ui-reference-main-console.png b/assets/generated/ui-reference-main-console.png
deleted file mode 100644
index b64a9d7..0000000
Binary files a/assets/generated/ui-reference-main-console.png and /dev/null differ
diff --git a/assets/generated/ui-reference-start-screen.png b/assets/generated/ui-reference-start-screen.png
deleted file mode 100644
index c8bffee..0000000
Binary files a/assets/generated/ui-reference-start-screen.png and /dev/null differ
diff --git a/assets/minigame-audio/bgm-anomaly-pressure-loop.wav b/assets/minigame-audio/bgm-anomaly-pressure-loop.wav
new file mode 100644
index 0000000..4c18e4a
Binary files /dev/null and b/assets/minigame-audio/bgm-anomaly-pressure-loop.wav differ
diff --git a/assets/minigame-audio/bgm-night-shift-loop.wav b/assets/minigame-audio/bgm-night-shift-loop.wav
new file mode 100644
index 0000000..6ad7666
Binary files /dev/null and b/assets/minigame-audio/bgm-night-shift-loop.wav differ
diff --git a/assets/minigame-audio/lockdown.wav b/assets/minigame-audio/lockdown.wav
index c8c3ba1..52aa9b6 100644
Binary files a/assets/minigame-audio/lockdown.wav and b/assets/minigame-audio/lockdown.wav differ
diff --git a/build.js b/build.js
index f1b9ee5..3953fa3 100644
--- a/build.js
+++ b/build.js
@@ -37,6 +37,33 @@ function stableStringifyObject(value, indent = 2) {
return JSON.stringify(value, null, indent);
}
+function sortJsonValue(value) {
+ if (Array.isArray(value)) return value.map(sortJsonValue);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.keys(value).sort().map(key => [key, sortJsonValue(value[key])]),
+ );
+ }
+ return value;
+}
+
+const V5_CONTENT_FILES = [
+ 'anomalies',
+ 'endings',
+ 'eventChains',
+ 'normalShifts',
+ 'passengers',
+ 'protocols',
+];
+
+function buildV5ContentInjection() {
+ const content = Object.fromEntries(V5_CONTENT_FILES.map(name => {
+ const filePath = path.join(ROOT, 'src', 'content', `${name}.json`);
+ return [name, sortJsonValue(JSON.parse(fs.readFileSync(filePath, 'utf-8')))];
+ }));
+ return `// --- V5 content (deterministic) ---\nvar __V5_CONTENT__ = ${JSON.stringify(content)};\n\n`;
+}
+
function applyReleaseOverrides(code, modPath, target, releaseConfig) {
const adUnits = releaseConfig?.[target]?.adUnits ?? releaseConfig?.adUnits;
if (modPath !== 'src/gameConfig.js' || !adUnits) return code;
@@ -74,6 +101,15 @@ const CORE_MODULES = [
{ path: 'src/skinManager.js', type: 'js' },
{ path: 'src/rollback.js', type: 'js' },
{ path: 'src/feedback.js', type: 'js' },
+ { path: 'src/protocolEngine.js', type: 'js' },
+ { path: 'src/evidenceEngine.js', type: 'js' },
+ { path: 'src/investigationTools.js', type: 'js' },
+ { path: 'src/identitySystem.js', type: 'js' },
+ { path: 'src/eventChainEngine.js', type: 'js' },
+ { path: 'src/highRiskResolution.js', type: 'js' },
+ { path: 'src/contamination.js', type: 'js' },
+ { path: 'src/debriefTimeline.js', type: 'js' },
+ { path: 'src/nightInteraction.js', type: 'js' },
{ path: 'src/anomalyContent.js', type: 'js' },
{ path: 'src/visualState.js', type: 'js' },
{ path: 'src/state.js', type: 'js' },
@@ -81,6 +117,7 @@ const CORE_MODULES = [
{ path: 'src/events.js', type: 'js' },
{ path: 'src/actions.js', type: 'js' },
{ path: 'src/uiLabels.js', type: 'js' },
+ { path: 'src/nightScheduler.js', type: 'js' },
{ path: 'src/runtimeSession.js', type: 'js' },
{ path: 'src/rewardGuard.js', type: 'js' },
{ path: 'src/firstRunGuidance.js', type: 'js' },
@@ -128,6 +165,46 @@ function stripESM(code) {
return code;
}
+function collectModuleExports(code) {
+ const exports = [];
+ const seen = new Set();
+ const add = (local, exported = local) => {
+ const key = `${local}:${exported}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ exports.push({ local, exported });
+ }
+ };
+
+ for (const match of code.matchAll(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm)) {
+ add(match[1]);
+ }
+ for (const match of code.matchAll(/^export\s*\{([^}]+)\};?\s*$/gm)) {
+ for (const specifier of match[1].split(',')) {
+ const [local, exported = local] = specifier.trim().split(/\s+as\s+/);
+ if (local) add(local, exported);
+ }
+ }
+ const defaultMatch = code.match(/^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m);
+ if (defaultMatch) add(defaultMatch[1]);
+ return exports;
+}
+
+function isolateModule(code, modPath) {
+ const moduleExports = collectModuleExports(code);
+ const stripped = stripESM(code);
+ const safeModuleId = modPath.replace(/[^A-Za-z0-9_$]/g, '_');
+ const exportBag = `__exports_${safeModuleId}`;
+ const captures = moduleExports
+ .map(({ local, exported }) => `${exportBag}[${JSON.stringify(exported)}] = ${local};`)
+ .join('\n');
+ const lifts = moduleExports
+ .map(({ exported }) => `var ${exported} = ${exportBag}[${JSON.stringify(exported)}];`)
+ .join('\n');
+
+ return `var ${exportBag} = {};\n{\n${stripped}\n${captures}\n}\n${lifts}`;
+}
+
function bundle(target) {
const modules = target === 'wechat' || target === 'douyin'
? MINI_ENTRY_MODULES
@@ -143,7 +220,7 @@ function bundle(target) {
`;
- let body = '';
+ let body = buildV5ContentInjection();
let bottom = '';
for (const mod of modules) {
@@ -164,19 +241,25 @@ function bundle(target) {
const min = JSON.stringify(skinData); // 压缩+验证
body += `// --- ${mod.path} ---\nvar __SKIN_DATA__ = ${min};\n\n`;
} else {
- let code = stripESM(raw);
- code = applyReleaseOverrides(code, mod.path, target, releaseConfig);
+ let code = applyReleaseOverrides(raw, mod.path, target, releaseConfig);
// 替换 skinManager 中的 import 为 __SKIN_DATA__
if (mod.path === 'src/skinManager.js') {
code = code.replace(/\bSKIN_DATA\b/g, '__SKIN_DATA__');
}
if (mod.path === 'src/events.js') {
- code = code.replace(/\n\s*\/\/ 向后兼容导出\s*\nfunction getHiddenLog\(anomalyId\)\s*\{\s*return _getHiddenLog\(anomalyId\);\s*\}\s*\n/g, '\n');
- code = code.replace(/\b_getHiddenLog\b/g, 'getHiddenLog');
+ code = code.replace(
+ /\n\s*\/\/ 向后兼容导出\s*\nexport\s+function getHiddenLog\(anomalyId\)\s*\{\s*return _getHiddenLog\(anomalyId\);\s*\}\s*\n/g,
+ '\n',
+ );
+ code = code.replace(
+ /(import\s+\{\s*getAnomalies,\s*getHiddenLog\s+as\s+_getHiddenLog,\s*t\s*\}\s+from\s+['"]\.\/skinManager\.js['"];?)/,
+ '$1\nconst _getHiddenLog = getHiddenLog;',
+ );
+ code = code.replace(/\b_getHiddenLog\b/g, 'skinHiddenLogLookup');
}
// 移除 skinManager 中死代码(buildHiddenLogsMap 的 require mock)
code = code.replace(/function buildHiddenLogsMap[\s\S]*?\n\}/g, 'function buildHiddenLogsMap() { return {}; }');
- body += `// --- ${mod.path} ---\n${code}\n\n`;
+ body += `// --- ${mod.path} ---\n${isolateModule(code, mod.path)}\n\n`;
}
}
@@ -208,6 +291,11 @@ console.log('[MINIGAME] Running on', '${target}');
const target = process.argv[2] || 'wechat';
const outputDir = path.join(ROOT, `${target}-minigame`);
fs.mkdirSync(outputDir, { recursive: true });
+const legacyOutputDir = path.join(outputDir, '电梯异常');
+if (fs.existsSync(legacyOutputDir)) {
+ fs.rmSync(legacyOutputDir, { recursive: true, force: true });
+ console.log(`[build] ✅ 已清理旧版输出目录: ${legacyOutputDir}`);
+}
const bundled = bundle(target);
const outPath = path.join(outputDir, 'game.js');
@@ -238,7 +326,7 @@ console.log(`[build] ✅ ${target} 构建完成`);
console.log(`[build] 输出: ${outPath}`);
console.log(`[build] 大小: ${(bundled.length / 1024).toFixed(1)} KB`);
-// ── 创建确定性公开项目配置(真实 AppID 只写入 ignored 私有配置) ──
+// ── 创建项目配置;存在 ignored release.config.json 时让开发工具直接读取真实 AppID ──
const projectConfig = target === 'douyin'
? {
description: 'MINIGAME - 异常电梯控制台(抖音小游戏)',
@@ -250,8 +338,8 @@ const projectConfig = target === 'douyin'
},
compileType: 'game',
libVersion: 'latest',
- appid: 'touristappid',
- projectname: '异常电梯控制台',
+ appid: releaseConfig?.douyin?.appid || 'touristappid',
+ projectname: releaseConfig?.douyin?.projectname || '异常电梯控制台',
condition: {},
}
: {
@@ -266,8 +354,8 @@ const projectConfig = target === 'douyin'
},
compileType: 'game',
libVersion: 'latest',
- appid: '请替换为你的微信小游戏 AppID',
- projectname: 'MINIGAME',
+ appid: releaseConfig?.wechat?.appid || 'touristappid',
+ projectname: releaseConfig?.wechat?.projectname || 'MINIGAME',
condition: {},
};
fs.writeFileSync(
@@ -281,7 +369,9 @@ const gameConfig = {
deviceOrientation: 'portrait',
showStatusBar: false,
networkTimeout: { request: 5000, connectSocket: 5000 },
- subPackages: [],
+ subPackages: [
+ { root: 'visual', name: 'v5-visual' },
+ ],
};
fs.writeFileSync(
path.join(outputDir, 'game.json'),
diff --git a/docs/IMAGE_GENERATION_PROMPT_PACK.md b/docs/IMAGE_GENERATION_PROMPT_PACK.md
index a746c41..6f54994 100644
--- a/docs/IMAGE_GENERATION_PROMPT_PACK.md
+++ b/docs/IMAGE_GENERATION_PROMPT_PACK.md
@@ -169,22 +169,19 @@ Do not make it look like a web form, survey page, SaaS admin dashboard, clean da
不要像表单、调查问卷、后台管理系统、SaaS 仪表盘、普通网页、普通 App、赛博朋克全息屏、卡通游戏、科幻飞船驾驶舱。
```
-## 9. 生成后放入项目的文件名
+## 9. 当前保留的运行时生成资产
-生成完图片后,建议按以下路径放入:
+项目清理后只保留已被 H5/CSS 实际引用的背景、纹理和干扰层:
```text
-assets/generated/cctv-basement-lift-real.png
assets/generated/cctv-hospital-ward-real.png
assets/generated/cctv-security-room-real.png
assets/generated/cctv-factory-real.png
assets/generated/cctv-subway-platform-real.png
assets/generated/cctv-hotel-lobby-real.png
-
-assets/generated/ui-reference-main-console.png
-assets/generated/ui-reference-start-screen.png
-assets/generated/ui-reference-failure-screen.png
-
+assets/generated/cctv-elevator-corridor-clear.png
+assets/generated/cctv-elevator-corridor-figure.png
+assets/generated/cctv-elevator-corridor-warp.png
assets/generated/overlay-cctv-noise.png
assets/generated/overlay-signal-tear.png
assets/generated/texture-hud-glass.png
@@ -193,11 +190,10 @@ assets/generated/texture-control-panel.png
## 10. 接入优先级
-1. 先接入 `cctv-basement-lift-real.png` 替换默认监控主图。
-2. 再接入 `texture-control-panel.png` 重做底部按钮区。
-3. 再接入 `texture-hud-glass.png` 重做面板质感。
-4. 再按皮肤切换 CCTV 背景:医院、安防、工厂、地铁、酒店。
-5. 最后接入异常态叠层:`overlay-cctv-noise.png`、`overlay-signal-tear.png`。
+1. 保持当前 CCTV 状态图和运行时 HUD 为正式玩家版本。
+2. `texture-control-panel.png`、`texture-hud-glass.png` 继续服务 H5 Debug Console。
+3. 其它皮肤继续使用各自已接线的背景。
+4. 异常态叠层继续使用 `overlay-cctv-noise.png`、`overlay-signal-tear.png`。
## 11. 验收标准
diff --git a/docs/MOBILE_GAMEPLAY_UI_V4.md b/docs/MOBILE_GAMEPLAY_UI_V4.md
index b2d2c98..6fabaa1 100644
--- a/docs/MOBILE_GAMEPLAY_UI_V4.md
+++ b/docs/MOBILE_GAMEPLAY_UI_V4.md
@@ -36,7 +36,7 @@ CCTV + 12 三项读数 108(楼层 / 人数 / 门)
## 视觉方向
-参考 `assets/generated/ui-reference-main-console.png`,复刻结构而不是只取黑绿色:
+延续已经落地的厚重、磨损机械监控台结构,而不是只取黑绿色:
- 主体是一块厚重、磨损的机械监控台;
- CCTV 是最大单一表面;
diff --git a/docs/NEXT_TASKS.md b/docs/NEXT_TASKS.md
index a62dc62..1697bc8 100644
--- a/docs/NEXT_TASKS.md
+++ b/docs/NEXT_TASKS.md
@@ -35,7 +35,7 @@ games/find-anomaly/elevator-console
- P3:皮肤生成脚本 `scripts/create-skin-from-template.mjs` 与 `npm run skin:new -- [名称]`。
- P2:轻量埋点接口 `src/analytics.js`,已接入 H5 开始、失败、广告、操作、异常路径。
- P2:跨局异常档案库已按皮肤记录场次、遭遇异常、解锁日志和收集进度。
-- P5:监控画面已接入更真实的低清 CCTV 背景 `assets/generated/monitor-cctv-real-basement-lift.png`,并在 Android WebView 资源准备脚本中打包验证。
+- P5:H5 Debug Console 的低清 CCTV 背景已由 `styles.css` 中当前仍被引用的生成资产提供;未接线旧候选图已清理。
- P5:图像生成提示词包已落地 `docs/IMAGE_GENERATION_PROMPT_PACK.md`,用于后续重做 UI/监控画面资产。
- P4:GitHub Actions verify workflow。
- P4:`npm run android:install` 真机/模拟器安装启动命令。
diff --git a/docs/PROJECT_CLEANUP_2026-07-12.md b/docs/PROJECT_CLEANUP_2026-07-12.md
new file mode 100644
index 0000000..c02287e
--- /dev/null
+++ b/docs/PROJECT_CLEANUP_2026-07-12.md
@@ -0,0 +1,53 @@
+# MINIGAME 项目清理报告(2026-07-12)
+
+## 容量变化
+
+- 清理前:约 4.6 GB
+- 清理后:约 1.04 GB(1060.3 MiB)
+- 释放:约 3.55 GB(约 77%)
+
+## 删除的本地可再生内容
+
+| 路径 | 清理前约占用 | 原因 |
+|---|---:|---|
+| `.tmp/` | 310.5 MiB | 截图、Chrome profile、审计和研究临时文件 |
+| `.gradle/` | 317.1 MiB | 可再生 Gradle 缓存 |
+| `android-webview/app/build/` | 159.4 MiB | 可再生 APK 与 Android 中间产物 |
+| `asset-handoff-hermes-2026-07-12/` | 54.8 MiB | 已完成导入的交接副本 |
+| `.tools/_downloads/` | 456 MiB | 已安装工具的下载压缩包 |
+| `.tools/douyin-devtools/` | 约 2.0 GiB | 与本机正式安装版重复的便携开发者工具 |
+| `.tools/android-sdk/build-tools/34.0.0/` | 137 MiB | 项目固定使用 35.0.0,旧版本无引用 |
+| `android-minigame/` | 13.9 MiB | `build.js android` 可确定性重建的中间产物 |
+| `android-webview/app/src/main/assets/` | 52.4 MiB | `android:prepare` 每次从源码重建的 WebView staging 目录 |
+
+## 删除的 tracked 未接线资产
+
+- 7 张 `assets/generated/` 旧候选图/参考屏幕。
+- 2 张仅供人工总览的 spritesheet/contact sheet。
+- 2 个未进入运行时的 SVG 图标文件。
+
+同步更新了 manifest、资产 README、设计文档与库存测试,仓库中不再保留指向已删除文件的陈旧路径。
+
+## 明确保留
+
+- `.tools/android-sdk`(Android 35 + build-tools 35.0.0 + platform-tools)。
+- `.tools/java/jdk-17`。
+- `.tools/gradle/gradle-8.10.2`。
+- 24 张宽屏 CCTV、24 张移动 CCTV、8 张按钮和 6 张 overlay 源资产。
+- 微信、抖音的 tracked import-ready 项目;Android WebView 工程壳保留,staging 资产由 `android:prepare` 按需生成。
+- `release-assets/*/screenshots/` 发布证据。
+- `assets/generated/` 中仍被 `styles.css` 或皮肤运行时引用的背景、纹理和干扰层。
+
+## 回归验证
+
+```text
+npm test: 260/260 pass
+npm run skins:check: 5/5 valid
+npm run content:v5:check: pass
+node build.js wechat: pass
+npm run douyin:build: pass
+npm run douyin:check: 0 runtime blockers
+npm run android:prepare: pass
+```
+
+为避免立即重新产生约 477 MiB 的 Gradle/APK缓存,本次清理后没有重新运行 `npm run android:build`;清理前提交 `c2a221b` 的 `verify:summary` 已确认 Android build 与 APK metadata 均为 OK,清理后 `android:prepare` 通过。下一次运行 Android 构建时 `.gradle/` 与 `app/build/` 会按需重建。
diff --git a/docs/PROJECT_EXECUTION_BOUNDARY.md b/docs/PROJECT_EXECUTION_BOUNDARY.md
new file mode 100644
index 0000000..3abe49a
--- /dev/null
+++ b/docs/PROJECT_EXECUTION_BOUNDARY.md
@@ -0,0 +1,49 @@
+# MINIGAME 项目执行边界
+
+> 本文件是 `D:/All projects/MINIGAME` 的项目级执行约束;不修改、不覆盖 Hermes 全局规则。
+
+## 允许范围
+
+- 仅修改本仓库:`D:/All projects/MINIGAME`。
+- 当前产品范围仅为 Game001《异常电梯》V5「夜班协议」。
+- 当前开发分支:`feat/game001-v5-night-protocol`。
+- 允许修改 V5 的 Canvas Runtime、玩法状态、内容、音效/震动、测试、构建产物和项目文档。
+- 每个功能切片必须有真实验收标准,按 RED → GREEN → 全量门禁 → commit → push 闭环。
+- 正式发布配置只能通过本机 ignored 配置注入;公开仓库只保留可移植的项目配置和构建逻辑。
+
+## 数据与产物边界
+
+- 临时截图、headless profile、测试输出、缓存和运行态只允许写入项目内 `.tmp/`、`.tmp-chrome*/`、`test-output/`、`coverage/` 或 `.hermes/`。
+- 保留 `.git/`、源代码、测试、运行时资产、音频、截图验收证据和唯一恢复文件。
+- `release.config.json`、`douyin-minigame/project.private.config.json`、`wechat-minigame/project.private.config.json` 不上传云端。
+- 不把任务状态、agent 输出、缓存或日志散落到 `C:/Users/ALEX`。
+
+## 明确禁止
+
+- 不修改 Hermes 全局 config、skills、plugins、memory、cron 或其他 profile。
+- 不访问、修改、删除、迁移其他项目,尤其是 Cognitive-Loop-OS 等 OS 系统项目。
+- 不开发第二个游戏、小游戏合集、商城、排行榜、皮肤扩张或 Unity/Cocos 重构。
+- 不把 `ui-v5-full` 的 393×852/360×640 完整参考图整张贴入 Runtime。
+- 不缩短 CCTV 窗口适配图片;必须让图片适配既有 CCTV 主布局。
+- 不制造 mock、占位截图、假 CI、假真机测试或重复空跑提交。
+- 不强推、不覆盖用户未提交内容。
+- 用户已打开抖音开发工具时,不重启、关闭或重新打开它;只能使用现有窗口进行编译/截图/交互验证。
+
+## 当前依赖顺序
+
+1. V5 身份回合正式结算(已完成)。
+2. 三条三阶段事件链正式 Runtime 调度、推进、后果与复盘接线(当前任务)。
+3. 事件链真实运行路径和双尺寸状态回归。
+4. 抖音开发工具现有窗口的完整 V5 实机路径验收(窗口不可用时必须明确标注未验收)。
+5. 包体、声光电和发布门禁收口。
+
+## 完成声明门禁
+
+只有同时具备以下证据,才能声明一个切片完成:
+
+- 代码路径被正式入口调用,而不只是文件或单元测试存在;
+- 失败路径和恢复/回滚行为有测试;
+- `npm test`、内容校验、`npm run douyin:check` 等适用门禁通过;
+- 生成 Bundle 与源代码一致;
+- commit SHA 已推送并通过远端 SHA 回读;
+- 官方模拟器/实机证据与测试证据分开陈述,未执行的部分不得伪称完成。
diff --git a/docs/V5_PHASE_A_FOUNDATION.md b/docs/V5_PHASE_A_FOUNDATION.md
new file mode 100644
index 0000000..2dc4480
--- /dev/null
+++ b/docs/V5_PHASE_A_FOUNDATION.md
@@ -0,0 +1,36 @@
+# Game001 V5 Phase A — 夜班协议数据地基
+
+## 范围
+
+本阶段只建立共享逻辑和数据契约,不改 Canvas V4 玩家界面,不引入第二游戏、新皮肤或旧式多按钮控制台。
+
+## 已建立的共享模块
+
+- `src/protocolEngine.js`:每局生成 2—3 条协议,并保证至少一条可作用于本局班次;协议判断输出可靠查证路径。
+- `src/evidenceEngine.js`:统一比较楼层、人数、门状态;判断前保持中性,不通过颜色或标题泄露答案;支持静音可判断性检查。
+- `src/contamination.js`:污染度 0—100、四阶段、因果历史和不泄题的视觉/可靠路径派生。
+- `src/state.js`:初始状态接入结构化污染度。
+
+## 内容容器
+
+- `src/content/protocols.json`:首批 6 条协议地基。
+- `src/content/normalShifts.json`:阶段 B 填充。
+- `src/content/anomalies.json`:阶段 B 填充。
+- `src/content/eventChains.json`:阶段 B 填充。
+- `src/content/endings.json`:阶段 D 填充。
+
+空容器是有意的阶段边界,不表示对应内容已经完成。
+
+## Schema
+
+`schemas/` 下包含 protocol、normal shift、anomaly content、event chain、ending 五类 JSON Schema。异常 schema 强制要求:画面数据、主控数据、明确冲突、决定、解释、处置、工具、协议标签、污染影响和静音替代证据。
+
+## 验证
+
+```bash
+node --test tests/protocolEngine.test.js tests/evidenceEngine.test.js tests/contamination.test.js tests/contentSchemasV5.test.js
+npm run content:v5:check
+npm test
+npm run skins:check
+npm run verify:summary
+```
diff --git a/docs/V5_PRE_AUDIT.md b/docs/V5_PRE_AUDIT.md
new file mode 100644
index 0000000..76de551
--- /dev/null
+++ b/docs/V5_PRE_AUDIT.md
@@ -0,0 +1,279 @@
+# Game001《异常电梯》V5 夜班协议升级:开发前审计
+
+> 审计日期:2026-07-12
+> 仓库:`DTALEX66/MINIGAME`
+> 工作目录:`D:\All projects\MINIGAME`
+> 范围:仅 Game001《异常电梯》;不扩展合集、第二游戏、皮肤、商城、排行榜或 Unity/Cocos。
+
+## 1. Git 基线
+
+```text
+分支:feat/game001-v5-night-protocol
+HEAD:31c8a7e6945f5b8dc57d37808ab546b4a374e64d
+短 SHA:31c8a7e
+提交:fix(game001): fit CCTV art into the fixed monitor viewport
+审计前工作树:clean
+审计后工作树:clean(本报告写入前)
+```
+
+最近关键提交:
+
+```text
+31c8a7e fix(game001): fit CCTV art into the fixed monitor viewport
+c2a221b feat(game001): replace baked-HUD art and integrate night-shift music
+920674b feat(game001): add V5 protocol evidence and contamination foundation
+fa8306f fix: remove only baked floor-route text from CCTV artwork
+```
+
+## 2. 安装与测试基线
+
+为避免只读审计生成新 lockfile,执行:
+
+```text
+npm install --package-lock=false
+up to date, audited 1 package
+0 vulnerabilities
+```
+
+全量测试:
+
+```text
+npm test
+260 tests
+260 pass
+0 fail
+```
+
+V5 内容校验:
+
+```text
+protocols.json: 6 entries
+normalShifts.json: 0 entries
+anomalies.json: 0 entries
+eventChains.json: 0 entries
+endings.json: 0 entries
+schemas and content containers valid
+```
+
+## 3. 当前项目结构
+
+### 核心游戏逻辑
+
+```text
+src/
+├── actions.js
+├── anomalyArchive.js
+├── anomalyContent.js
+├── archive.js
+├── contamination.js # V5 Phase A 已有
+├── evidenceEngine.js # V5 Phase A 已有
+├── events.js
+├── game.js
+├── gameConfig.js
+├── incidentDecision.js
+├── protocolEngine.js # V5 Phase A 已有
+├── runtimeSession.js
+├── state.js
+├── visualState.js
+└── content/
+ ├── protocols.json # 6 条
+ ├── normalShifts.json # 空
+ ├── anomalies.json # 空
+ ├── eventChains.json # 空
+ └── endings.json # 空
+```
+
+### 正式平台运行时
+
+```text
+platform/
+├── canvasRenderer.js # 正式 Canvas UI
+├── canvasAssets.js
+├── miniGameRuntime.js # 微信/抖音游戏循环
+├── miniGameAudio.js
+└── platform.js
+```
+
+### 当前 V5 测试
+
+```text
+tests/protocolEngine.test.js
+tests/evidenceEngine.test.js
+tests/contamination.test.js
+tests/contentSchemasV5.test.js
+```
+
+## 4. 当前正式 V4 玩家流程
+
+当前正式抖音/微信 Canvas 流程仍然是:
+
+```text
+启动授权
+ ↓
+大 CCTV
+ ↓
+读取画面楼层 / 人数 / 门状态
+ ↓
+与主控数据比较
+ ↓
+放行 / 封锁
+ ↓
+系统自动处置或进入下一班
+```
+
+已验证保留的优势:
+
+- 大 CCTV 第一视觉;
+- 竖屏单手布局;
+- 放行/封锁两个低门槛入口;
+- 工业监控风格;
+- Canvas Runtime;
+- 首三班渐进式教学;
+- 无七按钮控制台;
+- CCTV 素材无固定楼层答案和烘焙 HUD;
+- 常态/异常双轨 BGM 已接入生命周期。
+
+当前限制:
+
+1. 所有班次最终都归结为三字段一致性判断;
+2. 玩家没有主动调查步骤;
+3. 没有多摄像头空间证据;
+4. 没有乘客身份与胸牌核验;
+5. 协议系统没有进入正式运行循环和 UI;
+6. 班次之间缺少连续事件因果;
+7. 错误主要改变分数、稳定度和异常压力,没有改变后续夜班信息结构;
+8. 污染度只有数据模块和初始状态,尚未成为可玩的长期后果。
+
+## 5. 已完成的 V5 Phase A 地基
+
+### 5.1 夜班协议
+
+`src/protocolEngine.js` 已实现:
+
+- 每局选择 2—3 条不重复规则;
+- 保证至少一条规则对候选班次适用;
+- 条件比较与 release/lockdown 结果;
+- 输出 `verificationPaths`。
+
+`protocols.json` 已有 6 条初始协议,覆盖楼层、身份、设备、时间和人员。
+
+**缺口:** 尚未生成正式夜班 session;规则未进入 mini-game runtime、CCTV/UI 或班次判定主链。
+
+### 5.2 证据系统
+
+`src/evidenceEngine.js` 已实现:
+
+- 比较楼层、人数、门状态;
+- 合并协议冲突;
+- 判断前保持中性视觉;
+- 检查无音频时是否仍可判断。
+
+**缺口:** 尚无摄像头证据容器、证据发现状态、工具解锁证据、身份证据或事件链证据。
+
+### 5.3 污染度
+
+`src/contamination.js` 已实现:
+
+- 0—100 clamp;
+- normal/light/medium/severe 四档;
+- 因果历史;
+- 视觉干扰和可靠验证路径派生。
+
+`state.js` 已有污染度初始状态。
+
+**缺口:** 未由玩家错误/事件链改变;未进入班次生成、调查工具可靠性、UI、复盘或结局。
+
+## 6. V5 目标对照表
+
+| 系统 | 当前状态 | 证据 | Phase |
+|---|---|---|---|
+| 夜班协议 2—3 条 | ⚠️ 逻辑地基 | `protocolEngine.js`、6 条协议 | Phase 1 接线 |
+| 核心证据比较 | ⚠️ 逻辑地基 | `evidenceEngine.js` | Phase 1 扩展 |
+| 调查工具 | ❌ 缺失 | 无 `investigationTools.js` | Phase 1 |
+| CAM-01/03/07 | ❌ 缺失 | 当前单 CCTV 状态图 | Phase 1/3 |
+| 乘客身份 | ❌ 缺失 | 无 `identitySystem.js` / passengers | Phase 2 |
+| 30 异常模板 | ❌ 缺失 | V5 `anomalies.json` 为 0 | Phase 2 |
+| 10 正常班次 | ❌ 缺失 | `normalShifts.json` 为 0 | Phase 2 |
+| 3 条事件链 | ❌ 缺失 | `eventChains.json` 为 0 | Phase 2 |
+| 动态 roundType | ❌ 缺失 | 当前只有 normal/anomaly inspection | Phase 2/3 |
+| 动态按钮 | ❌ 缺失 | 当前固定放行/封锁 | Phase 3 |
+| 协议条/工具栏/摄像头切换 | ❌ 缺失 | Canvas 无相关入口 | Phase 3 |
+| 污染长期影响 | ⚠️ 数据地基 | `contamination.js` | Phase 4 |
+| 档案/复盘 | ⚠️ 旧异常时间线地基 | `anomalyArchive.js` | Phase 4 扩展 |
+| 结局 | ❌ 缺失 | `endings.json` 为 0 | Phase 4 |
+
+## 7. 架构决策
+
+1. **保留现有 Canvas Runtime,不重构引擎。**
+2. **保留大 CCTV 和基础放行/封锁。** 调查、身份、高危时才动态替换按钮。
+3. **新增系统必须是纯逻辑模块 + JSON 内容 + runtime 接线。** 不能只画 UI。
+4. **每条规则和异常必须具有至少两条可验证路径,音频不能是唯一证据。**
+5. **调查工具不能直接返回答案。** 工具只能揭示结构化证据。
+6. **污染度不直接决定正常/异常。** 它只改变延迟、设备可靠性和可用查证路径。
+7. **事件链状态跨班次保存,但每阶段推进必须由可观察事件触发。**
+8. **继续使用自定义 IIFE bundler。** 每个新增模块都必须在 `build.js` 中按依赖顺序注册。
+9. **首批 UI 不恢复永久七按钮。** 工具栏最多三个上下文工具,底部按钮由 `roundType` 动态生成。
+
+## 8. 严格开发顺序
+
+### Phase 1:玩法基础
+
+- 补强协议 session 与规则适用性;
+- 扩展多来源 evidence 模型;
+- 新增 `investigationTools.js`;
+- 首版热源扫描、三秒回放、协议查询;
+- 工具次数、电力消耗、非唯一答案约束;
+- 单元测试先行;
+- 不改正式 UI。
+
+### Phase 2:内容系统
+
+- 30 个异常模板;
+- 10 个正常班次;
+- 5 个乘客角色;
+- 3 条事件链;
+- 身份系统和事件链引擎;
+- 内容/schema/交叉引用测试。
+
+### Phase 3:UI 升级
+
+- 协议条;
+- CAM-01/03/07 切换;
+- 回放/热源/协议工具栏;
+- quick/investigation/identity/highRisk 动态按钮;
+- 393×852 与 360×640 官方运行截图。
+
+### Phase 4:留存与后果
+
+- 污染度接入决策后果;
+- 跨班次可信度变化;
+- 档案与局后时间线;
+- 结局内容;
+- 错误改变今晚后续班次而非立即结束。
+
+## 9. Phase 1 进入条件
+
+审计结论:**可以进入 Phase 1,但不能重复创建已有协议/证据/污染模块。**
+
+Phase 1 首个 TDD 垂直切片应为:
+
+```text
+生成夜班规则
+ ↓
+创建 investigation 班次证据集
+ ↓
+玩家消耗一次热源扫描
+ ↓
+系统仅揭示热源证据,不返回答案
+ ↓
+证据引擎结合协议与已发现证据给出可验证路径
+```
+
+验收门:
+
+- `protocolEngine.test.js`;
+- `evidenceEngine.test.js`;
+- 新增 `investigationTools.test.js`;
+- 全量 `npm test`;
+- `npm run content:v5:check`;
+- 独立 Phase 1 提交。
diff --git a/docs/V5_PROGRESS_REPORT.md b/docs/V5_PROGRESS_REPORT.md
new file mode 100644
index 0000000..0f9b0ff
--- /dev/null
+++ b/docs/V5_PROGRESS_REPORT.md
@@ -0,0 +1,950 @@
+# Game001 V5 夜班协议进度报告
+
+> 更新日期:2026-07-12
+> 当前分支:`feat/game001-v5-night-protocol`
+> 当前执行策略:玩法与内容优先;视觉、Canvas 布局和 CCTV 素材冻结。
+
+## 总体状态
+
+| 阶段 | 状态 | 说明 |
+|---|---|---|
+| Pre-Audit | ✅ 完成 | `docs/V5_PRE_AUDIT.md`,基线 260/260 |
+| Phase 1 玩法基础 | ✅ 完成 | 协议集合、调查证据、三类工具、多摄像头纯逻辑 |
+| Phase 2 内容系统 | ✅ 完成 | 30 异常、10 正常、5 乘客、3 事件链及引用测试 |
+| Phase 3 UI | 🔄 进行中 | ui-v5-full 已审计;8 张无文字 CCTV 场景已提取并进入三端资产清单 |
+| Phase 4 留存与后果 | ✅ 完成 | 污染后果、高危处置、复盘时间线、5 个结局纯逻辑 |
+
+## Phase 1:完成内容
+
+### 夜班协议
+
+- 保留已有每局 2—3 条协议生成逻辑。
+- 新增协议集合评估:只评估对当前班次适用的规则。
+- 输出适用协议、违反协议和合并后的可靠验证路径。
+- 规则不适用时不会随机改变班次答案。
+
+### 证据系统
+
+- 保留楼层、人数、门状态快速判断。
+- 新增调查证据评估。
+- 单个工具或单一来源不能直接定案。
+- 至少两条独立来源对同一 `conflictKey` 相互印证,才形成可提交的封锁结论。
+- 调查阶段输出保持 `presentationTone: neutral`。
+
+### 调查工具
+
+新增 `src/investigationTools.js`:
+
+- `thermal`:每局 2 次,每次消耗 8 电力;只揭示热源证据。
+- `replay`:每局 2 次,每次消耗 4 电力;只揭示三秒回放证据。
+- `protocol`:不限次数、0 电力;返回当前夜班协议。
+- 电力不足或次数耗尽时拒绝执行且不改变状态。
+- 返回值没有 `decision` 字段,工具不能成为直接答案按钮。
+
+### 多摄像头逻辑
+
+- 支持 CAM-01 / CAM-03 / CAM-07 由班次内容声明。
+- 切换摄像头时只返回该摄像头证据。
+- 不可用摄像头会被拒绝。
+- 已发现证据按 ID 去重保存。
+
+### 构建链
+
+- `investigationTools.js` 已加入自定义 IIFE bundler 模块顺序。
+- 本阶段未接 Canvas UI、未新增永久按钮。
+
+## 修改文件
+
+```text
+src/protocolEngine.js
+src/evidenceEngine.js
+src/investigationTools.js
+build.js
+tests/protocolEngine.test.js
+tests/evidenceEngine.test.js
+tests/investigationTools.test.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+## Phase 1 定向测试
+
+```text
+protocol + evidence + investigation tools
+14 tests
+14 pass
+0 fail
+```
+
+覆盖:
+
+- 规则影响判断;
+- 只评估适用规则;
+- 规则提供验证路径;
+- 单一工具证据不能直接定案;
+- 两条独立来源相互印证;
+- CAM 证据隔离;
+- 热源扫描消耗次数和电力;
+- 回放两次上限;
+- 电力不足拒绝;
+- 协议查询不消耗资源;
+- 工具结果不返回答案。
+
+## 截图
+
+Phase 1 不修改 UI,按用户要求不生成或更新视觉截图。正式截图留到 Phase 3 解冻后执行。
+
+## Phase 1 全量验证
+
+```text
+npm test: 267/267 pass
+npm run content:v5:check: pass
+npm run skins:check: 5/5 valid
+npm run douyin:build: pass
+npm run douyin:check: 16/17 pass, 1 tourist AppID warning, 0 runtime blockers
+```
+
+## Phase 2:内容与连续事件
+
+### 内容规模
+
+- `anomalies.json`:30 个异常模板,人物/数量/空间/时间/设备/动态各 5 个。
+- `normalShifts.json`:10 个画面与主控一致的正常班次。
+- `passengers.json`:5 个身份角色,包含胸牌、允许楼层、计数方式与核验路径。
+- `eventChains.json`:重复乘客、不存在楼层、摄像头替换三条三阶段事件链。
+
+### 身份系统
+
+- 胸牌和目标楼层均参与核验。
+- 维修员 `countMode: ignore` 真正改变主控人数计算。
+- 身份冲突返回明确字段和 CAM/协议核验路径。
+
+### 内容证据
+
+- 每个异常均声明 `roundType`、六类 `category`、多摄像头证据、热源证据和回放证据。
+- 每个异常至少有两条无音频验证路径。
+- 30 个异常均引用两个存在的正常变体。
+- 正常班次引用存在的乘客;事件链步骤引用存在的班次或异常。
+
+### 事件链
+
+- 每次只推进一个阶段。
+- 错误判断写入持久 flags,后续阶段可读取。
+- 链结束后按 flags 输出污染增量和后续班次修饰符。
+
+### Phase 2 验证
+
+```text
+Phase 2 定向测试:9/9 pass
+V5 内容校验:6 个容器全部通过
+npm test:276/276 pass
+抖音构建:pass
+抖音严格检查:16/17,1 个游客 AppID 警告,0 runtime blockers
+```
+
+### Phase 2 修改文件
+
+```text
+src/identitySystem.js
+src/eventChainEngine.js
+src/content/anomalies.json
+src/content/normalShifts.json
+src/content/passengers.json
+src/content/eventChains.json
+schemas/anomaly-content.schema.json
+schemas/normal-shift.schema.json
+schemas/passenger.schema.json
+scripts/validate-v5-content.mjs
+tests/identitySystem.test.js
+tests/eventChain.test.js
+tests/v5ContentPhase2.test.js
+tests/contentSchemasV5.test.js
+```
+
+### Phase 2 截图
+
+按用户要求视觉冻结,本阶段不修改 UI,也不生成替代视觉截图。
+
+## Phase 4:后果、处置与复盘(无视觉改动)
+
+### 污染与后续可信度
+
+- 正确/错误决定按内容配置改变污染值,并记录内容 ID 与因果。
+- 错误不会直接结束夜班。
+- 污染阶段逐步使 CAM-07、主控面板、其他摄像头变为不可靠来源。
+- 即使严重污染,仍保留热源和回放两条独立验证路径。
+- 污染效果不包含 `isAnomaly` 或 `correctDecision`,不会随机泄露答案。
+
+### 高危处置
+
+新增 `src/highRiskResolution.js`:
+
+- 支持急停、重启、封锁楼层三种上下文动作。
+- 动作具有真实电力成本。
+- 正确动作结算当前事件。
+- 错误动作写入 `nextShiftModifiers`,但 `gameOver` 保持 false。
+
+### 局后复盘与结局
+
+新增 `src/debriefTimeline.js`:
+
+- 合并决定、事件链阶段和污染历史并按顺序复盘。
+- 精确统计正确、错误、准确率、污染峰值和事件阶段数。
+- 结局按条件和优先级确定,事件链结局高于普通污染结局。
+- `endings.json` 已填充 5 个结局:替换信号、第十三层、带回来的夜班、清醒交班、未决记录。
+
+### Phase 4 验证
+
+```text
+Phase 4 定向测试:10/10 pass
+V5 内容校验:endings 5 entries,全部容器通过
+npm test:281/281 pass
+抖音构建:pass
+抖音严格检查:16/17,1 个游客 AppID 警告,0 runtime blockers
+```
+
+### Phase 4 修改文件
+
+```text
+src/contamination.js
+src/highRiskResolution.js
+src/debriefTimeline.js
+src/content/endings.json
+tests/contamination.test.js
+tests/highRiskResolution.test.js
+tests/debriefTimeline.test.js
+build.js
+```
+
+### Phase 4 截图
+
+按用户要求视觉冻结,本阶段不修改 UI,不生成替代截图。
+
+## Phase 3:视觉资产接收(进行中)
+
+资产源:
+
+```text
+asset-handoff-hermes-2026-07-12/ui-v5-full
+```
+
+执行结果:
+
+- `393x852/` 与 `360x640/` 的 16 张完整 UI 仅作布局参考,未进入运行包。
+- `source-gpt-image/` 的整张机柜源图未直接使用。
+- 按 `render_ui_v5.py` 的 `SCENE` 坐标只提取中央 CCTV 场景。
+- 输出 8 张 720×420、无文字、无 HUD、无机柜按钮的运行时素材。
+- 新增可重复生成脚本 `scripts/prepare-v5-ui-assets.py`。
+- `platform/canvasAssets.js` 新增 `v5Cctv` manifest、预加载和 `getV5Cctv()`。
+- 微信、抖音和 Android WebView 资产同步测试均覆盖新增素材。
+
+验证:
+
+```text
+Canvas/资产定向测试:5/5 pass
+npm test:281/281 pass
+抖音严格检查:16/17,0 runtime blockers
+抖音包体:18,141,296 bytes / 20 MB
+```
+
+限制:本提交只完成资产规范化和运行时登记;协议条、摄像头标签、工具栏、动态按钮仍须由 Canvas 绘制,禁止整屏贴图。
+
+## 构建作用域隔离:完成内容
+
+### 选择原因
+
+按睡觉循环优先级,`build.js` 的 `CORE_MODULES` 仍把所有去除 ESM 语法后的源码直接拼进同一个 IIFE 词法作用域;后续继续注入 V5 内容与 runtime 模块时,任意模块私有 `const` / `let` / `class` 同名都会让整个小游戏 bundle 无法执行。因此本轮先闭环构建安全性,而不提前接 UI 或调度。
+
+### 实现
+
+- 每个 JS 模块生成独立词法块,模块私有绑定不再共享 `CORE_MODULES` 全局作用域。
+- 构建时确定性收集命名导出、默认标识符导出和 `export { ... }`,在模块块结束后提升公开绑定,保持现有按依赖顺序执行的 IIFE 契约。
+- 保留 `events.js` 对皮肤隐藏日志函数的构建期别名兼容,同时移除发布检查禁止的 `_getHiddenLog` 残留。
+- 新增 bundle 执行回归测试:真实构建抖音包、断言模块隔离结构、在 Node VM 中执行 bundle,并调用 `createInitialState` 与 `createInvestigationState` 验证跨模块导出可用。
+
+### 本轮修改文件
+
+```text
+build.js
+tests/build.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:模块隔离/执行测试 0/1 pass(旧 bundle 没有模块词法块)
+GREEN 定向:build + Douyin bundle smoke 8/8 pass
+回归定向:Android/WebView + WeChat check + build + Douyin smoke 13/13 pass
+npm test:282/282 pass
+npm run douyin:build:pass,bundle 169.9 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,159,564 bytes / 20 MB
+```
+
+### 截图
+
+本轮是构建链纵向切片,不修改 Canvas 布局或视觉,不生成截图;393×852 / 360×640 完整参考图仍仅作验收依据,未作为运行时背景。
+
+## V5 内容确定性注入:完成内容
+
+### 选择原因
+
+构建作用域隔离已由最新提交 `3ec1a3c` 完成,因此按睡觉循环优先级跳到下一项。Phase 2 的六个 JSON 内容容器此前只在源码与校验脚本中存在,正式微信/抖音 bundle 无法读取,阻塞后续 `activeProtocols`、`currentShift` 与 `roundType` 调度。本轮只闭环内容注入,不提前改状态、调度或 Canvas UI。
+
+### 实现
+
+- `build.js` 使用固定容器顺序注入 `anomalies`、`endings`、`eventChains`、`normalShifts`、`passengers`、`protocols`。
+- 注入前递归按对象键排序;数组保持策划定义顺序,重复构建输出逐字节一致。
+- 六个容器合并为 bundle 内部 `__V5_CONTENT__`,可供后续 runtime 模块直接消费;未引入整屏 UI 参考图或额外视觉素材。
+- bundle VM 执行测试验证真实容器规模:30 异常、10 正常班次、5 乘客、6 协议,并检查全部六个容器及重复构建一致性。
+
+### 本轮修改文件
+
+```text
+build.js
+tests/build.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:build 定向 5/6 pass;新增内容注入测试因 bundle 缺少 __V5_CONTENT__ 按预期失败
+GREEN 定向:build + Douyin bundle smoke 9/9 pass
+V5 内容校验:protocols 6、normalShifts 10、anomalies 30、eventChains 3、passengers 5、endings 5,pass
+npm test:283/283 pass
+npm run douyin:build:pass,bundle 222.0 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,220,534 bytes / 20 MB
+```
+
+### 截图
+
+本轮只改变确定性构建数据,不修改 Canvas 布局或视觉,因此不生成截图;393×852 / 360×640 完整参考图仍未进入运行包。
+
+## V5 可回滚夜班状态:完成内容
+
+### 选择原因
+
+前两项构建作用域隔离与 V5 内容确定性注入已分别由 `3ec1a3c`、`fa632fe` 完成。按睡觉循环优先级,本轮选择扩展 `createInitialState`:正式调度前必须先让协议、当前班次、回合类型与调查资源进入统一状态树,并确保现有快照/广告复活不会丢失或浅拷贝这些嵌套数据。
+
+### 实现
+
+- `createInitialState()` 新增独立 `night` 状态,预留 `activeProtocols`、`currentShift`、`roundType`、班次索引、决定历史、事件链状态与后续班次修饰符。
+- 复用 `createInvestigationState()` 创建 `investigation`,初始电力与主状态一致,包含 CAM、热源/回放/协议工具和已发现证据。
+- 每次初始化均创建独立嵌套对象,避免新局之间共享协议或工具次数。
+- 现有 `cloneState`、`saveSnapshot`、`reviveFromAd` 的深拷贝链已用真实嵌套班次/证据验证:失败后的晚到数据不会污染快照,复活后的修改也不会反写快照。
+- 本轮只完成状态地基,不提前接调度或 Canvas UI;未引入任何完整 UI 参考图。
+
+### 本轮修改文件
+
+```text
+src/state.js
+tests/state.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:state 定向 19/21 pass;新增 night/investigation 初态与回滚测试按预期失败
+GREEN 定向:state + investigation tools + build 32/32 pass
+npm test:285/285 pass
+npm run douyin:build:pass,bundle 222.3 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,220,854 bytes / 20 MB
+```
+
+### 截图
+
+本轮只改变可回滚状态树,不修改 Canvas 布局或视觉,因此不生成截图;393×852 / 360×640 完整参考图仍未进入运行包。
+
+## V5 夜班内容调度:完成内容
+
+### 选择原因
+
+优先级 a—c 已由最近三个提交完成;正式 bundle 虽已拥有内容和可回滚状态树,但小游戏启动仍走 V4 空夜班状态。因而本轮选择闭环 `activeProtocols` / `currentShift` / `roundType` 调度,为下一轮原生 Canvas 协议条与 CAM tabs 提供真实、可测试的数据源。
+
+### 实现
+
+- 新增纯逻辑 `nightScheduler`,从 10 个正常班次、30 个异常和 6 条协议中接受可注入随机源进行确定选择。
+- 夜班启动固定先产生正常班次;后续班次按 normal/anomaly 交替调度,更新 `shiftIndex`、`shiftKind`、`currentShift` 与 `roundType`。
+- 每局生成 2—3 条不重复 `activeProtocols`,保证至少一条对候选内容适用,并把独立副本附着到当前班次,供工具查询和后续 Canvas 绘制使用。
+- 切换班次时重置 CAM-01、工具次数和已发现证据,不污染上一班调查状态。
+- `createRuntimeSession` / `restartRuntimeSession` 接受 bundle 内 `__V5_CONTENT__`;微信/抖音正式 Canvas runtime 启动与重开均实际初始化夜班调度。
+- 内容容器缺失时明确失败,不静默创建空回合;未修改 Canvas 布局,未使用完整 UI 参考图。
+
+### 本轮修改文件
+
+```text
+src/nightScheduler.js
+src/runtimeSession.js
+platform/miniGameRuntime.js
+build.js
+tests/nightScheduler.test.js
+tests/build.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:nightScheduler 定向 0/1 pass;模块不存在,新增测试按预期失败
+GREEN 定向:nightScheduler + runtimeSession + build + Douyin bundle smoke 15/15 pass
+npm test:288/288 pass
+npm run douyin:build:pass,bundle 225.1 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,223,720 bytes / 20 MB
+```
+
+### 截图
+
+本轮只接内容调度和正式小游戏 runtime 数据源,不修改 Canvas 布局或视觉,因此不生成截图;393×852 / 360×640 完整参考图仍未进入运行包。
+
+## V5 原生协议条与 CAM tabs:完成内容
+
+### 选择原因
+
+优先级 a—d 已由最近四个提交完成,正式 runtime 已具备真实 `activeProtocols`、`currentShift` 与 `roundType`,但 Canvas 仍只显示 V4 单条规则且无法切换班次摄像头。本轮因此闭环优先级 e,让调度数据首次成为可见、可交互的原生 Canvas UI,同时保持大 CCTV 为最大表面。
+
+### 实现
+
+- `canvasRenderer` 新增原生 `protocolBar`:从 `state.night.activeProtocols` 读取最多 3 条当前协议并实时绘制;前两班教学期间保留原有上下文提示,不泄露答案。
+- 新增原生 CAM-01 / CAM-03 / CAM-07 tabs:只展示当前班次证据实际提供的摄像头,并高亮 `investigation.activeCamera`。
+- CAM 点击命中区与绘制布局共用同一数据;正式小游戏 runtime 调用 `switchCamera`,切换后只发现对应摄像头证据,并提供点击音效与轻震反馈。
+- 布局为协议条与 CAM tabs 预留独立区域;1334 设计高度下 CCTV 仍至少 520px,并显著大于协议条与相机栏之和。未加入工具栏或永久七按钮。
+- 393×852 / 360×640 完整参考图未进入 bundle;所有协议文字、CAM 标签和状态均由 Canvas 实时绘制。
+
+### 本轮修改文件
+
+```text
+platform/canvasRenderer.js
+platform/miniGameRuntime.js
+tests/canvasRenderer.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:Canvas 定向测试无法导入 getCanvasCameraTabs,0/1 文件通过(缺少原生协议/CAM API)
+GREEN 定向:Canvas renderer + runtime + investigation tools 36/36 pass
+bundle 回归定向:Canvas + runtime + Douyin smoke 34/34 pass
+npm test:291/291 pass
+npm run douyin:build:pass,bundle 228.3 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,226,886 bytes / 20 MB
+```
+
+### 截图
+
+本轮完成原生 Canvas 结构与交互,但精准双尺寸截图验收属于优先级 h,须在工具、动态动作和协议查询等正式 UI 接线完成后统一执行;本轮不生成不完整验收图。完整 UI 参考图仍仅作布局依据,未作为运行时背景。
+
+## V5 原生工具栏与动态动作:完成内容
+
+### 选择原因
+
+优先级 a—e 已由最近提交闭环;正式 Canvas 已能显示协议和切换 CAM,但玩家仍无法使用 Phase 1 的热源、回放、协议工具,底部也未消费 `roundType`。本轮因此选择优先级 f,将已有调查逻辑接入原生 Canvas,并用上下文动作替换固定控制层,为下一轮分类、高危和复盘主链提供可交互入口。
+
+### 实现
+
+- 新增三枚原生 Canvas 工具:热源扫描、三秒回放、夜班协议;实时显示剩余次数、电力成本和禁用状态,永远不超过三枚。
+- 工具点击命中与绘制共用布局;正式小游戏 runtime 调用 `useInvestigationTool()`,同步调查电力、已发现证据和反馈,不直接返回正确答案。
+- 底部动作按 `night.roundType` 派生:`quick` 仅放行/封锁,`investigation` 仅标记疑点/进入分类,`identity` 仅放行/拒绝/核验;不恢复永久七按钮。
+- 保留首两班引导和第三班独立 quick 判断,修复初版动态动作导致 bundle 教学回归缺少放行/封锁的问题。
+- 为工具栏重新分配纵向预算;360px 宽设备上的主要动作高度仍超过 48px,CCTV 保持至少 520 设计像素并继续是最大单一表面。
+- 所有工具、读数和动作均由 Canvas 实时绘制;393×852 / 360×640 完整参考图未进入运行包。
+
+### 本轮修改文件
+
+```text
+platform/canvasRenderer.js
+platform/miniGameRuntime.js
+tests/canvasRenderer.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:Canvas 定向 0/1 文件通过;缺少 getCanvasToolButtons 导出,按预期失败
+GREEN 定向:Canvas renderer + mini-game runtime + investigation tools 39/39 pass
+教学回归初检:npm test 293/294;动态 roundType 提前替换第三班 quick 动作
+修复后 Douyin bundle smoke:3/3 pass
+npm test:294/294 pass
+npm run douyin:build:pass,bundle 232.1 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,231,061 bytes / 20 MB
+```
+
+### 截图
+
+本轮完成工具与动态动作结构接线,但 classification / highRisk / debrief 尚未进入正式交互主链;按既定优先级,双尺寸精准截图留到优先级 h 统一验收,避免提交不完整状态截图。
+
+## V5 协议查询、分类、高危与复盘主链:完成内容
+
+### 选择原因
+
+优先级 a—f 已由最近提交完成;Canvas 虽已显示工具与动态动作,但协议工具仍只有反馈文字,classification / highRisk / debrief 也没有正式业务结算。该缺口会让 V5 在调查阶段断链,因此本轮选择优先级 g,以纯逻辑状态机、原生 Canvas 和正式小游戏 runtime 一次闭环交互主链,不提前进行双尺寸截图精修。
+
+### 实现
+
+- 新增 `nightInteraction` 纯逻辑模块:协议查询覆盖层开关不消耗次数或电力;六类异常分类记录决定并应用污染后果;高危急停/重启/封锁楼层消耗真实电力,错误只写入后续班次修饰符且不结束夜班;局后复盘从真实决定与污染历史生成时间线、准确率和确定性结局。
+- 原生 Canvas 新增六类 classification 动作、三项 highRisk 动作,以及协议查询/局后复盘覆盖层;按钮继续按 `roundType` 动态替换,没有恢复永久七按钮。
+- 正式小游戏 runtime 接入协议工具覆盖层、进入分类、分类结算、高危结算、下一班调度和 game-over 复盘;业务状态与 Canvas 点击共用正式 API。
+- `nightInteraction.js` 加入自定义 IIFE bundle,微信/抖音生成包均已更新并通过真实 VM 启动回归。
+- CCTV 布局和既有无文字场景资产未改;393×852 / 360×640 完整参考图仍未作为运行时背景。
+
+### 本轮修改文件
+
+```text
+src/nightInteraction.js
+platform/canvasRenderer.js
+platform/miniGameRuntime.js
+build.js
+tests/nightInteraction.test.js
+tests/canvasRenderer.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED 逻辑:nightInteraction 定向 0/1 文件通过;模块不存在,按预期失败
+RED Canvas:Canvas 定向 0/1 文件通过;缺少 getCanvasOverlayModel,按预期失败
+GREEN 定向:nightInteraction + Canvas renderer 31/31 pass
+V5 内容校验:protocols 6、normalShifts 10、anomalies 30、eventChains 3、passengers 5、endings 5,pass
+npm test:299/299 pass
+npm run douyin:build:pass,bundle 241.0 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,240,411 bytes / 20 MB
+```
+
+### 截图
+
+本轮完成正式交互主链,但双尺寸精准截图属于下一优先级 h;本轮不提交未经精修的验收图。所有协议、分类、高危动作和复盘文字均由 Canvas 实时绘制。
+
+## 双尺寸 V5 竖屏布局验收与精准修复:完成内容
+
+### 选择原因
+
+优先级 a—g 已由最近提交全部闭环,剩余最高价值缺口是官方 `393×852` 与 `360×640` 双尺寸验收。原布局在 393×852 上把 CCTV 拉到约 425px,超过 handoff 的 360px;在 360×640 上又固定为约 250px,挤压底部区域。该偏差会破坏短屏完整可达性,并使 bundle smoke 的点击坐标与真实动作区漂移。
+
+### 实现
+
+- `getCanvasLayout()` 按两个官方视口锚点计算 CCTV 高度:360×640 为 230px,393×852 为 360px,中间设备线性插值。
+- CCTV 仍是最大的玩法表面;没有缩成装饰窗,也没有产生底部空洞。
+- 两个视口的反馈区均保持在屏内,主动作实际触控高度均不低于 48px。
+- 更新真实 Douyin bundle 点击 smoke,使教学放行/封锁命中新的动作区中心。
+- 继续只使用无文字 CCTV 场景;协议、CAM、读数、工具、动态动作和覆盖层均由 Canvas 实时绘制,完整 UI 参考图未进入运行包。
+
+### 本轮修改文件
+
+```text
+platform/canvasRenderer.js
+tests/canvasRenderer.test.js
+tests/douyinBundleSmoke.test.js
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:Canvas 定向 27/28 pass;393×852 CCTV 高度与 handoff 不符,按预期失败
+GREEN 定向:Canvas renderer 28/28 pass
+Canvas + 可执行 Douyin bundle smoke:31/31 pass
+npm test:300/300 pass
+npm run douyin:build:pass,bundle 241.3 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,240,678 bytes / 20 MB
+```
+
+### 截图验收
+
+本轮运行环境没有可用桌面应用窗口(后台桌面枚举为空),因此未伪造“实机截图”文件。双尺寸几何验收直接以 `UI_V5_DIMENSIONS.json` 的官方像素值驱动可执行布局测试,并由真实 Douyin bundle Canvas 点击 smoke 验证交互命中。视觉截图仍需在安装抖音开发者工具的图形环境中补采;这不影响本轮布局、构建和包体验收门禁。
+
+## 正式 Canvas 双尺寸截图补采:完成内容
+
+### 选择原因
+
+优先级 a—h 的逻辑、接线和几何验收已由最新提交完成,但上一轮因桌面应用枚举为空,仍缺少 `393×852` 与 `360×640` 的真实运行画面证据。本轮选择补齐该最后视觉门禁,并在截图中发现协议长文在短屏横向截断,因此同时闭环一项可测试的精准修复。
+
+### 实现与截图
+
+- 新增 `scripts/v5-canvas-acceptance.html`,直接加载正式 `canvasRenderer` 与正式初始状态,按查询参数生成指定设备尺寸的 V5 classification 场景;它是验收入口,不是第二套 UI。
+- 使用 Windows Chrome headless 在 `393×852`、`360×640` 两个官方视口真实执行 Canvas renderer,生成:
+ - `docs/screenshots/v5-runtime-393x852.png`
+ - `docs/screenshots/v5-runtime-360x640.png`
+- 截图确认 CCTV 始终为最大单一表面、反馈区完整在屏内、六类动态分类按钮可读,没有永久七按钮或底部空洞。
+- 针对初次截图暴露的协议横向截断,新增确定性短摘要:每条协议保留 9 个字符并加省略号,使两条当前协议在 360px 短屏同时可见;完整协议仍可通过“夜班协议”覆盖层查询。
+- 截图只使用正式 renderer 绘制的协议、CAM、读数、工具、分类和反馈;没有把 handoff 完整参考图作为背景。
+- 新增 PNG 尺寸回归测试,要求两张运行截图存在且像素尺寸严格为 393×852 / 360×640。
+
+### 本轮修改文件
+
+```text
+platform/canvasRenderer.js
+scripts/v5-canvas-acceptance.html
+tests/canvasRenderer.test.js
+tests/v5ScreenshotAcceptance.test.js
+docs/screenshots/v5-runtime-393x852.png
+docs/screenshots/v5-runtime-360x640.png
+wechat-minigame/game.js
+douyin-minigame/game.js
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED 截图门禁:0/1 pass;验收入口与双尺寸 PNG 均不存在
+RED 协议短屏:Canvas 测试无法导入 getCanvasProtocolSummary,按预期失败
+GREEN 定向:Canvas + 截图门禁 30/30 pass
+npm test:302/302 pass
+npm run douyin:build:pass,bundle 241.7 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音包体:18,241,123 bytes / 20 MB
+```
+
+## Identity 状态双尺寸截图验收:完成内容
+
+### 选择原因
+
+优先级 a—h 及 classification 双尺寸截图已完成;进度报告明确剩余最高优先是逐阶段补采 identity / highRisk / protocol-query / debrief。按“每轮一个纵向切片”,本轮先选择 identity:它是尚未有正式截图证据的首个动态动作状态,且三列身份动作在 360px 短屏最容易出现文字或触控区拥挤。
+
+### 实现与截图
+
+- 扩展既有 `scripts/v5-canvas-acceptance.html`,按 `round=identity` 注入确定的身份班次、楼层、人数和反馈;仍直接执行正式 `createInitialState` 与 `canvasRenderer`,没有复制第二套 UI。
+- 使用 Windows Chrome headless 在两个官方视口生成:
+ - `docs/screenshots/v5-runtime-identity-393x852.png`
+ - `docs/screenshots/v5-runtime-identity-360x640.png`
+- 目视验收确认:CCTV 在两种尺寸均为最大单一表面;放行/拒绝/核验三项身份动作同时可见;反馈完整;没有永久七按钮、底部空洞或整屏参考图背景。
+- 截图回归门禁扩展为四张正式运行图,严格校验 PNG 签名与像素尺寸。
+- `.gitignore` 忽略 headless Chrome 的仓库内临时 profile;未删除或修改 `asset-handoff`。
+
+### 本轮修改文件
+
+```text
+.gitignore
+scripts/v5-canvas-acceptance.html
+tests/v5ScreenshotAcceptance.test.js
+docs/screenshots/v5-runtime-identity-393x852.png
+docs/screenshots/v5-runtime-identity-360x640.png
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:截图门禁 0/1 pass;缺少 v5-runtime-identity-393x852.png,按预期失败
+GREEN 定向:Canvas + 截图门禁 30/30 pass
+npm test:302/302 pass
+npm run douyin:build:pass,bundle 241.7 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音检查口径包体:18,241,123 bytes / 20 MB
+```
+
+## HighRisk 状态双尺寸截图验收:完成内容
+
+### 选择原因
+
+优先级 a—h、classification 与 identity 双尺寸截图均已完成;剩余阶段截图中 highRisk 优先级最高。该状态同时承载三项带真实电力成本的处置按钮,360px 短屏最需要确认动作文字、成本和反馈没有拥挤或截断,因此本轮只闭环 highRisk 视觉证据。
+
+### 实现与截图
+
+- 扩展既有 `scripts/v5-canvas-acceptance.html`,按 `round=highRisk` 注入确定的设备异常班次、13 层读数、三名乘客与高危反馈;仍直接执行正式 `createInitialState` 与 `canvasRenderer`,没有复制第二套 UI。
+- 使用 Windows Chrome headless 在两个官方视口生成:
+ - `docs/screenshots/v5-runtime-high-risk-393x852.png`
+ - `docs/screenshots/v5-runtime-high-risk-360x640.png`
+- 目视验收确认:CCTV 在两种尺寸均为最大单一表面;急停、重启、封锁楼层三项动作及 15/10/12 电力成本完整可读;反馈未截断;没有永久七按钮、底部空洞或整屏参考图背景。
+- 截图回归门禁扩展为六张正式运行图,严格校验 PNG 签名与像素尺寸。
+- 未修改或删除 `asset-handoff`;所有协议、读数、工具和按钮继续由 Canvas 实时绘制。
+
+### 本轮修改文件
+
+```text
+scripts/v5-canvas-acceptance.html
+tests/v5ScreenshotAcceptance.test.js
+docs/screenshots/v5-runtime-high-risk-393x852.png
+docs/screenshots/v5-runtime-high-risk-360x640.png
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:截图门禁 0/1 pass;缺少 v5-runtime-high-risk-393x852.png,按预期失败
+GREEN 定向:Canvas + 截图门禁 30/30 pass
+npm test:302/302 pass
+npm run douyin:build:pass,bundle 241.7 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音检查口径包体:18,241,123 bytes / 20 MB
+```
+
+## Protocol Query 状态双尺寸截图验收:完成内容
+
+### 选择原因
+
+优先级 a—h、classification、identity 与 highRisk 双尺寸截图均已完成;剩余阶段截图中 protocol-query 是当前最高优先。该覆盖层必须在 360px 短屏完整展示当前协议全文和返回入口,同时保持底层大 CCTV 语境,因此本轮只闭环协议查询视觉证据。
+
+### 实现与截图
+
+- 扩展既有 `scripts/v5-canvas-acceptance.html`,按 `round=protocolQuery` 注入确定的调查班次,并设置正式 `night.overlay` / `night.protocolQuery` 状态;仍直接执行正式 `createInitialState` 与 `canvasRenderer`,没有复制第二套 UI。
+- 使用 Windows Chrome headless 在两个官方视口生成:
+ - `docs/screenshots/v5-runtime-protocol-query-393x852.png`
+ - `docs/screenshots/v5-runtime-protocol-query-360x640.png`
+- 目视验收确认:底层 CCTV 在两种尺寸均保持最大玩法表面;两条协议全文与“返回监控”按钮完整可读;反馈未截断;没有永久七按钮、底部空洞或整屏参考图背景。
+- 截图回归门禁扩展为八张正式运行图,严格校验 PNG 签名与像素尺寸。
+- 未修改或删除 `asset-handoff`;协议、读数、工具、按钮和覆盖层继续由 Canvas 实时绘制。
+
+### 本轮修改文件
+
+```text
+scripts/v5-canvas-acceptance.html
+tests/v5ScreenshotAcceptance.test.js
+docs/screenshots/v5-runtime-protocol-query-393x852.png
+docs/screenshots/v5-runtime-protocol-query-360x640.png
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:截图门禁 0/1 pass;缺少 v5-runtime-protocol-query-393x852.png,按预期失败
+GREEN 定向:Canvas + 截图门禁 30/30 pass
+npm test:302/302 pass
+npm run douyin:build:pass,bundle 241.7 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音检查口径包体:18,241,123 bytes / 20 MB
+```
+
+## Debrief 状态双尺寸截图验收:完成内容
+
+### 选择原因
+
+优先级 a—h 以及 classification、identity、highRisk、protocol-query 的双尺寸截图均已完成;剩余阶段截图中 debrief 是唯一未闭环的正式 Canvas 视觉门禁。本轮按“一轮一个纵向切片”只补采局后复盘,重点验证 360px 短屏中的结局摘要、统计与返回入口。
+
+### 实现与截图
+
+- 扩展既有 `scripts/v5-canvas-acceptance.html`,按 `round=debrief` 注入确定的真实复盘数据形状:8 次判断、75% 准确率、污染峰值 34 与“清醒交班”结局;仍直接执行正式 `createInitialState` 与 `canvasRenderer`,没有复制第二套 UI。
+- 使用 Windows Chrome headless 在两个官方视口生成:
+ - `docs/screenshots/v5-runtime-debrief-393x852.png`
+ - `docs/screenshots/v5-runtime-debrief-360x640.png`
+- 目视验收确认:底层 CCTV 仍是最大玩法表面;复盘标题、判断统计、污染峰值、结局摘要与“返回监控”完整可读;反馈未截断;没有永久七按钮、底部空洞或整屏参考图背景。
+- 截图回归门禁扩展为十张正式运行图,严格校验 PNG 签名与像素尺寸。
+- 未修改或删除 `asset-handoff`;协议、读数、工具、按钮和复盘覆盖层继续由 Canvas 实时绘制。
+
+### 本轮修改文件
+
+```text
+scripts/v5-canvas-acceptance.html
+tests/v5ScreenshotAcceptance.test.js
+docs/screenshots/v5-runtime-debrief-393x852.png
+docs/screenshots/v5-runtime-debrief-360x640.png
+docs/V5_PROGRESS_REPORT.md
+```
+
+### TDD 与真实验证
+
+```text
+RED:截图门禁 0/1 pass;缺少 v5-runtime-debrief-393x852.png,按预期失败
+GREEN 定向:Canvas + 截图门禁 30/30 pass
+npm test:302/302 pass
+npm run douyin:build:pass,bundle 241.7 KB
+npm run douyin:check:16/17 pass,1 个游客 AppID warning,0 runtime blockers
+抖音检查口径包体:18,241,123 bytes / 20 MB
+```
+
+## 第 15 轮:抖音真实 AppID 接入与 Lite 模式根因修复
+
+### 选择原因
+
+用户提供正式抖音小游戏 AppID `ttfd408bfd63251fff02`,但开发工具无法修改。实测根因是 `build.js` 每次构建都把 `douyin-minigame/project.config.json` 重写为 `touristappid`,开发工具因而持续处于 Lite/游客模式;手工在工具内修改也会被下一次构建覆盖。
+
+### 实现
+
+- 本地 ignored `release.config.json` 保存正式 AppID,不上传广告配置或账号凭据。
+- `build.js` 在存在本地抖音发布配置时,将真实 AppID 同时写入开发工具实际读取的 `project.config.json` 和 ignored `project.private.config.json`;没有本地配置的 CI/其他克隆仍回退 `touristappid`。
+- tracked 抖音项目配置更新为用户提供的正式 AppID,开发工具重新编译后游戏成功刷新,未出现 AppID 配置错误。
+- 新增回归测试,验证 release config 必须注入主项目配置,防止再次出现“私有配置正确但开发工具仍是游客模式”。
+
+### 验证
+
+```text
+build 定向测试:6/6 pass
+npm test:302/302 pass
+npm run douyin:check:17/17 pass,0 warning,0 runtime blockers
+project.config.json AppID:ttfd408bfd63251fff02
+project.private.config.json AppID:ttfd408bfd63251fff02
+抖音包体:18,241,210 bytes / 20 MB
+```
+
+### 工具会话说明
+
+重新编译已成功读取配置并刷新游戏。开发工具标题栏仍显示“退出 Lite 模式”,这是当前开发工具会话状态;原生标题栏需要用户手动点击一次“退出 Lite 模式”或关闭后重新导入 `douyin-minigame`。代码侧不再覆盖真实 AppID。
+
+### 循环终止
+
+本轮为用户指定的第 15 轮。完成测试、提交和推送后停止睡觉循环,不执行第 16 轮。
+
+## V5 声音与震动语义反馈增强
+
+### 真实缺口
+
+V5 工具和动态动作虽然已经可交互,但热源扫描、三秒回放、协议查询和进入分类此前全部复用普通点击音;高危拒绝也缺少震动,玩家无法仅凭声光电反馈区分调查行为和风险等级。
+
+### 实现
+
+- 新增纯函数 `getV5FeedbackProfile()`,集中定义 V5 行为的音效与震动强度。
+- CAM 切换:点击音 + 轻震。
+- 热源扫描:警戒音 + 中震。
+- 三秒回放:电机音 + 轻震。
+- 夜班协议:启动提示音 + 轻震。
+- 进入身份/异常分类:警戒音 + 中震。
+- 分类正确:封锁音 + 中震;分类错误:错误音 + 重震。
+- 高危正确处置:封锁音 + 重震;高危错误或动作被拒绝:错误音 + 重震。
+- 关闭协议/复盘覆盖层:点击音 + 轻震。
+- 复用已有本地短音效,没有新增音频素材或整屏资源。
+
+### 验证
+
+```text
+音频 + runtime 定向测试:13/13 pass
+npm test:303/303 pass
+npm run douyin:check:17/17 pass,0 warning,0 runtime blockers
+抖音包体:18,242,347 bytes / 20 MB
+```
+
+## V5 身份核验正式结算修复
+
+### 真实缺口
+
+事件链审计发现 `identity` 回合的“核验”此前会直接把 `roundType` 改为 `classification`。正常身份班次没有异常 `category`,因此玩家核验后选择任何六类分类都会被判错;“放行/拒绝”又落入通用电梯动作,未记录夜班决定,也不影响污染或后续班次。
+
+### 实现
+
+- 新增 `verifyCurrentIdentity()`:核验只揭示当前身份班次的 CAM-01 证据,保持 `identity` 回合,不提交答案、不泄露直接判定。
+- 新增 `resolveIdentityDecision()`:正常身份期望放行,异常身份期望拒绝;记录 `identity:release/reject` 决定。
+- 错误身份判断按内容配置增加污染,但不直接结束夜班。
+- Runtime 正式接线 `identityVerify`、`identityRelease`、`identityReject`;结算后调度下一班。
+- 身份核验、正确结算和错误结算均复用 V5 语义音效与震动。
+- 该修复解除三阶段事件链第一阶段的真实阻塞,不新增永久按钮。
+
+### 验证
+
+```text
+night interaction + runtime 定向测试:14/14 pass
+npm test:305/305 pass
+npm run douyin:check:17/17 pass,0 warning,0 runtime blockers
+抖音包体:18,245,075 bytes / 20 MB
+```
+
+## 三阶段事件链 Runtime 接入
+
+### 真实缺口
+
+此前 `eventChains.json` 和 `eventChainEngine.js` 只有内容/纯逻辑证据,正式夜班调度没有读取事件链,玩家无法稳定遇到三阶段后续事件。
+
+### 已实现
+
+- `createNightSchedule()` 初始化事件链状态,但首三班教学保持原路径;
+- 教学完成后,`scheduleNextNightShift()` 按事件链步骤绑定真实 `contentId`、`roundType` 和步骤 ID;
+- 身份放行/拒绝、异常分类、高危处置均通过统一 Runtime 出口推进当前事件链;
+- 每个阶段记录正确/错误历史;
+- 错误阶段写入事件链 flags;
+- 三阶段完成后按 flags 应用污染增量和下一班修饰符;
+- 复盘优先读取 `night.eventChainHistory`,并为每条链保留同步的 `history` 视图,确保三阶段不会从局后时间线丢失;
+
+### 验证
+
+- 调度/事件链/身份/Runtime 定向测试:`19/19`;
+- 全量测试:`307/307`;
+- 抖音严格检查:`17/17`;
+- 包体:`18,248,898 bytes / 20 MB`;
+- Runtime blockers:`0`。
+
+### 尚未声称完成的部分
+
+当前证据证明的是正式 Bundle 的代码接线、内容绑定和自动化回归;抖音开发者工具现有窗口的完整三阶段人工操作仍需单独验收,不能用自动化测试替代。
+
+## V5 视觉质量修复(审计后第一轮)
+
+### 修复项
+
+1. **CCTV 比例失真**:393×852 下直接铺满造成 1.54× 纵向拉伸,改为 cover 居中铺满——窗口尺寸不变、无黑边、无拉伸,两侧边缘裁切、轿厢主体居中完整。
+2. **V5 交接美术首次真正上屏**:`getV5CctvScreenId()` 按回合(quick/investigation/identity/classification/highRisk/protocolQuery)选择 V5 场景图,运动/异常瞬时态回退 24 状态机图。同时修复验收 harness 从未加载真实资产的根因(浏览器无 wx/tt/canvas.createImage,`init` 新增可注入 `options.imageFactory`,发布 bundle 保持零 document/window 引用)。
+3. **威胁态红框**:`getCanvasCctvTreatment` 新增 border 色(威胁红/glitch 琥珀/实体紫/稳定绿),修复 strokeStyle=undefined 静默失效。
+4. **动态扫描光束**:pending 状态 sweep 覆盖层随可暂停帧时钟自上而下扫过,替代静态横带。
+5. **按钮按压反馈**:`noteCanvasPress` + `drawPressShade`,CAM 标签/工具/决策按钮点击后有 180ms 下沉暗化+高亮描边。
+6. **协议摘要截断 9→14 字**:保留"必须封锁/不属于异常"等结论子句。
+7. **结算页中文化**:failureEyebrow `SYSTEM FAILURE` → `系统故障`。
+
+### 验证
+
+- 双尺寸 10 张截图重新捕获:各回合 CCTV 像素 diff 12.4–35.4(此前为 0,证明 V5 场景真实上屏);
+- canvasRenderer 定向测试 32/32,相关套件 38/38;
+- 全量测试 310/310;
+- 抖音严格检查 17/17、0 blocker、包体 18,252,234 bytes / 20 MB。
+
+## V5 视觉氛围修复(审计后第二轮)
+
+用户反馈上一轮“没有一点氛围”,复核确认构图缺陷不是素材加载,而是 CCTV 监控层资产只预加载未绘制:`overlay_cctv_frame`、`overlay_scanlines`、`overlay_vignette` 未进入 `drawCctvScene`。
+
+本轮只改 CCTV 表现层:
+
+- 新增 `drawCctvAtmosphere`,将扫描线、镜头暗角、CRT 扫描带、录制红点、`CAM-03 // NIGHT WATCH` 运行时角标和监控角框真正叠到主画面;
+- 威胁态使用红色脉冲内框,普通态使用低亮度绿框,状态变化有镜头语义而不是只变按钮颜色;
+- 所有氛围层被 CCTV clip 限制,不覆盖协议条、工具栏或决策按钮;
+- 保留 cover 裁切与真实 V5 场景图,不回退到程序占位图。
+
+验收:上一版与本版 393×852 截图尺寸一致,128×128 归一化后 27.8% 区域发生变化;全量测试 312/312,抖音严格检查 17/17、0 blocker,包体 18,255,074 bytes / 20 MB。
+
+## V5 夜班协议全量接线(2026-07-27)
+
+### 本轮真实修复
+
+- Runtime 在教学结束时安装事件链首步并创建新的 pending inspection;后续决策和超时才推进事件链,避免跳步或进入不可操作状态;
+- quick 回合 `release/lockdown` 使用 decision 分流,身份、协议关闭和判断结果使用独立音频语义;
+- debrief ending 使用 `eventChainFlags`,`nextShiftModifiers` 独立保留;
+- `duplicate_feed`、`floor_13_bleed`、`unreliable_cam07` 在下一班安装时一次性消费,并转换为可观察的 CCTV visualState;
+- 决策、事件链和污染记录开始使用统一 `timelineSequence`;
+- V5 `visualState` 接入 Canvas CCTV treatment;`14_duplicate_subject` 通过显式资产 alias 映射到已发布影子主体素材;
+- `visual/` 声明为 `v5-visual` subpackage,主包非视觉部分约 2.96 MB,总包约 18.26 MB;构建会清理旧版 `电梯异常` 输出目录;
+- 微信构建默认不再生成中文 AppID 占位符,正式 release 仍必须通过私有 release config 注入真实 AppID。
+
+### 本轮门禁证据
+
+```text
+npm test 318/318 pass
+npm run douyin:check 17/17 pass, 0 runtime blocker
+wechat strict bundle check 8/8 pass, 0 blocker
+git diff --check pass
+wechat total package 18,259,743 bytes
+wechat non-visual main portion 2,959,773 bytes
+douyin total package 18,259,816 bytes
+remote exact SHA d3e20b09d62e467fed9d7efd4d5222d49c80e86b
+```
+
+本轮提交已推送至 `feat/game001-v5-night-protocol`。自动化证据不替代真实微信/抖音开发者工具和真机人工验收;正式发布仍需真实 AppID、广告位及平台验收。
+
+## 剩余问题
+
+1. 发布仍需真实微信/抖音 AppID 与广告位配置;当前构建产物仅使用游客/开发 fallback,release readiness 应继续 fail-closed。
+2. 真实微信/抖音开发者工具、生命周期、InnerAudioContext、触摸和真机视觉验收仍需单独执行,不能由浏览器截图或自动化测试替代。
+3. 后续只处理真实 Game001 V5 验收发现,不扩展平台或新游戏。
diff --git a/docs/screenshots/v5-runtime-360x640.png b/docs/screenshots/v5-runtime-360x640.png
new file mode 100644
index 0000000..8f347f4
Binary files /dev/null and b/docs/screenshots/v5-runtime-360x640.png differ
diff --git a/docs/screenshots/v5-runtime-393x852.png b/docs/screenshots/v5-runtime-393x852.png
new file mode 100644
index 0000000..0794d74
Binary files /dev/null and b/docs/screenshots/v5-runtime-393x852.png differ
diff --git a/docs/screenshots/v5-runtime-debrief-360x640.png b/docs/screenshots/v5-runtime-debrief-360x640.png
new file mode 100644
index 0000000..dc8b243
Binary files /dev/null and b/docs/screenshots/v5-runtime-debrief-360x640.png differ
diff --git a/docs/screenshots/v5-runtime-debrief-393x852.png b/docs/screenshots/v5-runtime-debrief-393x852.png
new file mode 100644
index 0000000..bbff6b4
Binary files /dev/null and b/docs/screenshots/v5-runtime-debrief-393x852.png differ
diff --git a/docs/screenshots/v5-runtime-high-risk-360x640.png b/docs/screenshots/v5-runtime-high-risk-360x640.png
new file mode 100644
index 0000000..bac432c
Binary files /dev/null and b/docs/screenshots/v5-runtime-high-risk-360x640.png differ
diff --git a/docs/screenshots/v5-runtime-high-risk-393x852.png b/docs/screenshots/v5-runtime-high-risk-393x852.png
new file mode 100644
index 0000000..67da84e
Binary files /dev/null and b/docs/screenshots/v5-runtime-high-risk-393x852.png differ
diff --git a/docs/screenshots/v5-runtime-identity-360x640.png b/docs/screenshots/v5-runtime-identity-360x640.png
new file mode 100644
index 0000000..e2a8d05
Binary files /dev/null and b/docs/screenshots/v5-runtime-identity-360x640.png differ
diff --git a/docs/screenshots/v5-runtime-identity-393x852.png b/docs/screenshots/v5-runtime-identity-393x852.png
new file mode 100644
index 0000000..fbca61f
Binary files /dev/null and b/docs/screenshots/v5-runtime-identity-393x852.png differ
diff --git a/docs/screenshots/v5-runtime-protocol-query-360x640.png b/docs/screenshots/v5-runtime-protocol-query-360x640.png
new file mode 100644
index 0000000..fda976f
Binary files /dev/null and b/docs/screenshots/v5-runtime-protocol-query-360x640.png differ
diff --git a/docs/screenshots/v5-runtime-protocol-query-393x852.png b/docs/screenshots/v5-runtime-protocol-query-393x852.png
new file mode 100644
index 0000000..f27e1fe
Binary files /dev/null and b/docs/screenshots/v5-runtime-protocol-query-393x852.png differ
diff --git a/douyin-minigame/audio/bgm-anomaly-pressure-loop.wav b/douyin-minigame/audio/bgm-anomaly-pressure-loop.wav
new file mode 100644
index 0000000..4c18e4a
Binary files /dev/null and b/douyin-minigame/audio/bgm-anomaly-pressure-loop.wav differ
diff --git a/douyin-minigame/audio/bgm-night-shift-loop.wav b/douyin-minigame/audio/bgm-night-shift-loop.wav
new file mode 100644
index 0000000..6ad7666
Binary files /dev/null and b/douyin-minigame/audio/bgm-night-shift-loop.wav differ
diff --git a/douyin-minigame/audio/lockdown.wav b/douyin-minigame/audio/lockdown.wav
index c8c3ba1..52aa9b6 100644
Binary files a/douyin-minigame/audio/lockdown.wav and b/douyin-minigame/audio/lockdown.wav differ
diff --git a/douyin-minigame/game.js b/douyin-minigame/game.js
index 3ae792c..4ac0c45 100644
--- a/douyin-minigame/game.js
+++ b/douyin-minigame/game.js
@@ -6,7 +6,12 @@
(function() {
'use strict';
+// --- V5 content (deterministic) ---
+var __V5_CONTENT__ = {"anomalies":[{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"duplicate_face","contradicts":true,"id":"duplicate_face_cam01","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam01"}],"cam03":[{"conflictKey":"duplicate_face","contradicts":true,"id":"duplicate_face_cam03","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam03"}],"cam07":[{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_cam07","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam07"}]},"replay":{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_replay","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"replay"},"thermal":{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_thermal","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"thermal"}},"explanation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","highRisk":false,"id":"person_duplicate_face","name":"重复面孔","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"duplicate_face","protocolDependent":false,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"wrong_badge","contradicts":true,"id":"wrong_badge_cam01","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam01"}],"cam03":[{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_cam03","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam03"}],"cam07":[{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_cam07","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam07"}]},"replay":{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_replay","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"replay"},"thermal":{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_thermal","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"thermal"}},"explanation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","highRisk":false,"id":"person_wrong_badge","name":"错误胸牌","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"wrong_badge","protocolDependent":true,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"cold_passenger","contradicts":true,"id":"cold_passenger_cam01","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam01"}],"cam03":[{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_cam03","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam03"}],"cam07":[{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_cam07","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam07"}]},"replay":{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_replay","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"replay"},"thermal":{"conflictKey":"cold_passenger","contradicts":true,"id":"cold_passenger_thermal","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"thermal"}},"explanation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","highRisk":false,"id":"person_cold_passenger","name":"无热源人物","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"cold_passenger","protocolDependent":false,"protocolTags":["person"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","thermal"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"unknown_identity","contradicts":true,"id":"unknown_identity_cam01","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam01"}],"cam03":[{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_cam03","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam03"}],"cam07":[{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_cam07","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam07"}]},"replay":{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_replay","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"replay"},"thermal":{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_thermal","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"thermal"}},"explanation":"乘客工号不在当夜授权名单,目标楼层却被请求。","highRisk":false,"id":"person_unknown_identity","name":"不存在的工号","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"unknown_identity","protocolDependent":true,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"shadow_mismatch","contradicts":true,"id":"shadow_mismatch_cam01","observation":"一名乘客对应两道独立移动的影子。","source":"cam01"}],"cam03":[{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_cam03","observation":"一名乘客对应两道独立移动的影子。","source":"cam03"}],"cam07":[{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_cam07","observation":"一名乘客对应两道独立移动的影子。","source":"cam07"}]},"replay":{"conflictKey":"shadow_mismatch","contradicts":true,"id":"shadow_mismatch_replay","observation":"一名乘客对应两道独立移动的影子。","source":"replay"},"thermal":{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_thermal","observation":"一名乘客对应两道独立移动的影子。","source":"thermal"}},"explanation":"一名乘客对应两道独立移动的影子。","highRisk":false,"id":"person_shadow_mismatch","name":"影子人数异常","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"shadow_mismatch","protocolDependent":false,"protocolTags":["person"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"panel_undercount","contradicts":true,"id":"panel_undercount_cam01","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam01"}],"cam03":[{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_cam03","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam03"}],"cam07":[{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_cam07","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam07"}]},"replay":{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_replay","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"replay"},"thermal":{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_thermal","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"thermal"}},"explanation":"CAM-01 可见两名乘客,主控只记录一人。","highRisk":false,"id":"count_panel_undercount","name":"主控少计一人","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":7,"passengers":2},"primaryConflict":"panel_undercount","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam01","panel"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"empty_weight","contradicts":true,"id":"empty_weight_cam01","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam01"}],"cam03":[{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_cam03","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam03"}],"cam07":[{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_cam07","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam07"}]},"replay":{"conflictKey":"empty_weight","contradicts":true,"id":"empty_weight_replay","observation":"轿厢无人,但载重连续两次记录为一人。","source":"replay"},"thermal":{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_thermal","observation":"轿厢无人,但载重连续两次记录为一人。","source":"thermal"}},"explanation":"轿厢无人,但载重连续两次记录为一人。","highRisk":false,"id":"count_empty_weight","name":"空厢载重","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":8,"passengers":0},"primaryConflict":"empty_weight","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"maintenance_counted","contradicts":true,"id":"maintenance_counted_cam01","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam01"}],"cam03":[{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_cam03","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam03"}],"cam07":[{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_cam07","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam07"}]},"replay":{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_replay","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"replay"},"thermal":{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_thermal","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"thermal"}},"explanation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","highRisk":false,"id":"count_maintenance_counted","name":"维修员计数错误","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":9,"passengers":2},"primaryConflict":"maintenance_counted","protocolDependent":true,"protocolTags":["count","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":9,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"reflection_count","contradicts":true,"id":"reflection_count_cam01","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam01"}],"cam03":[{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_cam03","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam03"}],"cam07":[{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_cam07","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam07"}]},"replay":{"conflictKey":"reflection_count","contradicts":true,"id":"reflection_count_replay","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"replay"},"thermal":{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_thermal","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"thermal"}},"explanation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","highRisk":false,"id":"count_reflection_count","name":"倒影独立计数","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":10,"passengers":0},"primaryConflict":"reflection_count","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":10,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_cam01","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam01"}],"cam03":[{"conflictKey":"exit_without_decrement","contradicts":true,"id":"exit_without_decrement_cam03","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam03"}],"cam07":[{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_cam07","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam07"}]},"replay":{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_replay","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"replay"},"thermal":{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_thermal","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"thermal"}},"explanation":"CAM-03 显示乘客离开,主控人数仍未减少。","highRisk":false,"id":"count_exit_without_decrement","name":"离开后未减员","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":11,"passengers":2},"primaryConflict":"exit_without_decrement","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":11,"passengers":1},"silentEvidence":["cam03","panel"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_cam01","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam01"}],"cam03":[{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_cam03","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam03"}],"cam07":[{"conflictKey":"floor_13","contradicts":true,"id":"floor_13_cam07","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam07"}]},"replay":{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_replay","observation":"楼层请求指向协议中不存在的 13 层。","source":"replay"},"thermal":{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_thermal","observation":"楼层请求指向协议中不存在的 13 层。","source":"thermal"}},"explanation":"楼层请求指向协议中不存在的 13 层。","highRisk":true,"id":"space_floor_13","name":"不存在楼层","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"closed","floor":13,"passengers":1},"primaryConflict":"floor_13","protocolDependent":true,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":13,"passengers":1},"silentEvidence":["cam07","protocol"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_cam01","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam01"}],"cam03":[{"conflictKey":"wrong_corridor","contradicts":true,"id":"wrong_corridor_cam03","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam03"}],"cam07":[{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_cam07","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam07"}]},"replay":{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_replay","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"replay"},"thermal":{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_thermal","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"thermal"}},"explanation":"CAM-03 显示的走廊结构与目标楼层档案不符。","highRisk":false,"id":"space_wrong_corridor","name":"错误走廊","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":1,"passengers":1},"primaryConflict":"wrong_corridor","protocolDependent":true,"protocolTags":["space"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":1,"passengers":1},"silentEvidence":["cam03","protocol"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"simultaneous_cameras","contradicts":true,"id":"simultaneous_cameras_cam01","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam01"}],"cam03":[{"conflictKey":"simultaneous_cameras","contradicts":true,"id":"simultaneous_cameras_cam03","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam03"}],"cam07":[{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_cam07","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam07"}]},"replay":{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_replay","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"replay"},"thermal":{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_thermal","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"thermal"}},"explanation":"同一乘客同时出现在 CAM-01 与 CAM-03。","highRisk":true,"id":"space_simultaneous_cameras","name":"双处出现","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"simultaneous_cameras","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_cam01","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam01"}],"cam03":[{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_cam03","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam03"}],"cam07":[{"conflictKey":"shaft_entry","contradicts":true,"id":"shaft_entry_cam07","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam07"}]},"replay":{"conflictKey":"shaft_entry","contradicts":true,"id":"shaft_entry_replay","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"replay"},"thermal":{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_thermal","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"thermal"}},"explanation":"CAM-07 记录到乘客在轿厢到达前进入井道。","highRisk":true,"id":"space_shaft_entry","name":"井道提前进入","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"shaft_entry","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_to_wall","contradicts":true,"id":"door_to_wall_cam01","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam01"}],"cam03":[{"conflictKey":"door_to_wall","contradicts":true,"id":"door_to_wall_cam03","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam03"}],"cam07":[{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_cam07","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam07"}]},"replay":{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_replay","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"replay"},"thermal":{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_thermal","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"thermal"}},"explanation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","highRisk":false,"id":"space_door_to_wall","name":"门后墙体","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"door_to_wall","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"motion_loop","contradicts":true,"id":"motion_loop_cam01","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam01"}],"cam03":[{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_cam03","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam03"}],"cam07":[{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_cam07","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam07"}]},"replay":{"conflictKey":"motion_loop","contradicts":true,"id":"motion_loop_replay","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"replay"},"thermal":{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_thermal","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"thermal"}},"explanation":"三秒回放显示乘客动作逐帧完全重复。","highRisk":false,"id":"time_motion_loop","name":"动作循环","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"motion_loop","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_cam01","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam01"}],"cam03":[{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_cam03","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam03"}],"cam07":[{"conflictKey":"clock_stall","contradicts":true,"id":"clock_stall_cam07","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam07"}]},"replay":{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_replay","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"replay"},"thermal":{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_thermal","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"thermal"}},"explanation":"CAM-07 时间码停止,但主控时钟继续前进。","highRisk":false,"id":"time_clock_stall","name":"时间停止","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"clock_stall","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam07","panel"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_cam01","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam01"}],"cam03":[{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_cam03","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam03"}],"cam07":[{"conflictKey":"early_arrival","contradicts":true,"id":"early_arrival_cam07","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam07"}]},"replay":{"conflictKey":"early_arrival","contradicts":true,"id":"early_arrival_replay","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"replay"},"thermal":{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_thermal","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"thermal"}},"explanation":"井道记录显示轿厢在调度命令前已经到站。","highRisk":false,"id":"time_early_arrival","name":"提前到达","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":7,"passengers":1},"primaryConflict":"early_arrival","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_cam01","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam01"}],"cam03":[{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_cam03","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam03"}],"cam07":[{"conflictKey":"delay_overrun","contradicts":true,"id":"delay_overrun_cam07","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam07"}]},"replay":{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_replay","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"replay"},"thermal":{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_thermal","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"thermal"}},"explanation":"CAM-07 延迟超过协议允许的固定两秒。","highRisk":false,"id":"time_delay_overrun","name":"延迟超限","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":8,"passengers":1},"primaryConflict":"delay_overrun","protocolDependent":true,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1},"silentEvidence":["cam07","protocol"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"future_frame","contradicts":true,"id":"future_frame_cam01","observation":"回放中出现三秒后才发生的开门动作。","source":"cam01"}],"cam03":[{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_cam03","observation":"回放中出现三秒后才发生的开门动作。","source":"cam03"}],"cam07":[{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_cam07","observation":"回放中出现三秒后才发生的开门动作。","source":"cam07"}]},"replay":{"conflictKey":"future_frame","contradicts":true,"id":"future_frame_replay","observation":"回放中出现三秒后才发生的开门动作。","source":"replay"},"thermal":{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_thermal","observation":"回放中出现三秒后才发生的开门动作。","source":"thermal"}},"explanation":"回放中出现三秒后才发生的开门动作。","highRisk":true,"id":"time_future_frame","name":"未来帧","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":9,"passengers":1},"primaryConflict":"future_frame","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":9,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_state","contradicts":true,"id":"door_state_cam01","observation":"画面中门已开启,主控仍报告关闭。","source":"cam01"}],"cam03":[{"conflictKey":"door_state","contradicts":false,"id":"door_state_cam03","observation":"画面中门已开启,主控仍报告关闭。","source":"cam03"}],"cam07":[{"conflictKey":"door_state","contradicts":false,"id":"door_state_cam07","observation":"画面中门已开启,主控仍报告关闭。","source":"cam07"}]},"replay":{"conflictKey":"door_state","contradicts":false,"id":"door_state_replay","observation":"画面中门已开启,主控仍报告关闭。","source":"replay"},"thermal":{"conflictKey":"door_state","contradicts":false,"id":"door_state_thermal","observation":"画面中门已开启,主控仍报告关闭。","source":"thermal"}},"explanation":"画面中门已开启,主控仍报告关闭。","highRisk":false,"id":"device_door_state","name":"门状态冲突","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"open","floor":10,"passengers":1},"primaryConflict":"door_state","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":10,"passengers":1},"silentEvidence":["cam01","panel"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_cam01","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam01"}],"cam03":[{"conflictKey":"floor_sensor","contradicts":true,"id":"floor_sensor_cam03","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam03"}],"cam07":[{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_cam07","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam07"}]},"replay":{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_replay","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"replay"},"thermal":{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_thermal","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"thermal"}},"explanation":"CAM-03 楼层标识与主控楼层传感器不一致。","highRisk":false,"id":"device_floor_sensor","name":"楼层传感错误","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":11,"passengers":1},"primaryConflict":"floor_sensor","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":11,"passengers":1},"silentEvidence":["cam03","panel"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_cam01","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam01"}],"cam03":[{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_cam03","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam03"}],"cam07":[{"conflictKey":"camera_substitution","contradicts":true,"id":"camera_substitution_cam07","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam07"}]},"replay":{"conflictKey":"camera_substitution","contradicts":true,"id":"camera_substitution_replay","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"replay"},"thermal":{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_thermal","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"thermal"}},"explanation":"CAM-07 时间码变化但画面像素完全不变。","highRisk":true,"id":"device_camera_substitution","name":"画面被替换","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":12,"passengers":1},"primaryConflict":"camera_substitution","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":12,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"thermal_ghost","contradicts":true,"id":"thermal_ghost_cam01","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam01"}],"cam03":[{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_cam03","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam03"}],"cam07":[{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_cam07","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam07"}]},"replay":{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_replay","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"replay"},"thermal":{"conflictKey":"thermal_ghost","contradicts":true,"id":"thermal_ghost_thermal","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"thermal"}},"explanation":"空轿厢出现移动热源,三台摄像头均无人。","highRisk":false,"id":"device_thermal_ghost","name":"虚假热源","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":1,"passengers":1},"primaryConflict":"thermal_ghost","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":1,"passengers":1},"silentEvidence":["thermal","cam01"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_cycle","contradicts":true,"id":"door_cycle_cam01","observation":"维护状态下门循环超过协议允许的一次。","source":"cam01"}],"cam03":[{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_cam03","observation":"维护状态下门循环超过协议允许的一次。","source":"cam03"}],"cam07":[{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_cam07","observation":"维护状态下门循环超过协议允许的一次。","source":"cam07"}]},"replay":{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_replay","observation":"维护状态下门循环超过协议允许的一次。","source":"replay"},"thermal":{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_thermal","observation":"维护状态下门循环超过协议允许的一次。","source":"thermal"}},"explanation":"维护状态下门循环超过协议允许的一次。","highRisk":false,"id":"device_door_cycle","name":"门循环超限","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"door_cycle","protocolDependent":true,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"instant_shift","contradicts":true,"id":"instant_shift_cam01","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam01"}],"cam03":[{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_cam03","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam03"}],"cam07":[{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_cam07","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam07"}]},"replay":{"conflictKey":"instant_shift","contradicts":true,"id":"instant_shift_replay","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"replay"},"thermal":{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_thermal","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"thermal"}},"explanation":"乘客在相邻帧从轿厢左侧瞬移到门外。","highRisk":false,"id":"dynamic_instant_shift","name":"人物瞬移","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"instant_shift","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"delayed_shadow","contradicts":true,"id":"delayed_shadow_cam01","observation":"乘客停止后影子仍继续移动两秒。","source":"cam01"}],"cam03":[{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_cam03","observation":"乘客停止后影子仍继续移动两秒。","source":"cam03"}],"cam07":[{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_cam07","observation":"乘客停止后影子仍继续移动两秒。","source":"cam07"}]},"replay":{"conflictKey":"delayed_shadow","contradicts":true,"id":"delayed_shadow_replay","observation":"乘客停止后影子仍继续移动两秒。","source":"replay"},"thermal":{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_thermal","observation":"乘客停止后影子仍继续移动两秒。","source":"thermal"}},"explanation":"乘客停止后影子仍继续移动两秒。","highRisk":false,"id":"dynamic_delayed_shadow","name":"影子延迟","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"delayed_shadow","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"reverse_walk","contradicts":true,"id":"reverse_walk_cam01","observation":"乘客向前行走但位置持续向后移动。","source":"cam01"}],"cam03":[{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_cam03","observation":"乘客向前行走但位置持续向后移动。","source":"cam03"}],"cam07":[{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_cam07","observation":"乘客向前行走但位置持续向后移动。","source":"cam07"}]},"replay":{"conflictKey":"reverse_walk","contradicts":true,"id":"reverse_walk_replay","observation":"乘客向前行走但位置持续向后移动。","source":"replay"},"thermal":{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_thermal","observation":"乘客向前行走但位置持续向后移动。","source":"thermal"}},"explanation":"乘客向前行走但位置持续向后移动。","highRisk":false,"id":"dynamic_reverse_walk","name":"逆向动作","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"reverse_walk","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"frozen_passenger","contradicts":true,"id":"frozen_passenger_cam01","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam01"}],"cam03":[{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_cam03","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam03"}],"cam07":[{"conflictKey":"frozen_passenger","contradicts":true,"id":"frozen_passenger_cam07","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam07"}]},"replay":{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_replay","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"replay"},"thermal":{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_thermal","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"thermal"}},"explanation":"轿厢震动时乘客轮廓保持像素级静止。","highRisk":false,"id":"dynamic_frozen_passenger","name":"局部静止","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"frozen_passenger","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam01","cam07"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_crossing","contradicts":true,"id":"door_crossing_cam01","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam01"}],"cam03":[{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_cam03","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam03"}],"cam07":[{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_cam07","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam07"}]},"replay":{"conflictKey":"door_crossing","contradicts":true,"id":"door_crossing_replay","observation":"门关闭期间人物轮廓穿过实体门板。","source":"replay"},"thermal":{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_thermal","observation":"门关闭期间人物轮廓穿过实体门板。","source":"thermal"}},"explanation":"门关闭期间人物轮廓穿过实体门板。","highRisk":true,"id":"dynamic_door_crossing","name":"穿门而过","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":7,"passengers":1},"primaryConflict":"door_crossing","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"}],"endings":[{"conditions":{"requiredFlag":"camera_chain_compromised"},"id":"camera_taken","name":"替换信号","priority":5,"summary":"你完成了值守,但 CAM-07 留下的已不是今晚的画面。"},{"conditions":{"requiredFlag":"floor_chain_compromised"},"id":"floor_consumed","name":"第十三层","priority":5,"summary":"主控恢复正常,楼层表却从此多出一个无法删除的编号。"},{"conditions":{"minContamination":76},"id":"contaminated_survivor","name":"带回来的夜班","priority":3,"summary":"你活过了夜班,但系统污染已经跟随档案进入下一次值守。"},{"conditions":{"maxContamination":25,"minAccuracy":0.8},"id":"clean_shift","name":"清醒交班","priority":1,"summary":"每次决定都有证据支撑。晨班接管时,所有摄像头仍可信。"},{"conditions":{},"id":"uncertain_shift","name":"未决记录","priority":0,"summary":"你完成了交班,但有几段记录无法证明究竟发生过什么。"}],"eventChains":[{"consequences":[{"contaminationDelta":18,"flag":"chain_compromised","nextShiftModifier":"duplicate_feed"}],"id":"duplicate_passenger","initialFlags":[],"steps":[{"contentId":"normal_shift_02","id":"first_visit","onWrongFlags":["trusted_duplicate"],"roundType":"identity","trigger":"first_duplicate_candidate"},{"contentId":"person_duplicate_face","id":"repeated_motion","onWrongFlags":["motion_ignored"],"roundType":"investigation","trigger":"trusted_duplicate_or_next_shift"},{"contentId":"space_simultaneous_cameras","id":"simultaneous_presence","onWrongFlags":["chain_compromised"],"roundType":"highRisk","trigger":"second_duplicate_seen"}]},{"consequences":[{"contaminationDelta":22,"flag":"floor_chain_compromised","nextShiftModifier":"floor_13_bleed"}],"id":"nonexistent_floor","initialFlags":[],"steps":[{"contentId":"device_floor_sensor","id":"floor_flash","onWrongFlags":["floor_flash_ignored"],"roundType":"quick","trigger":"floor_display_flash"},{"contentId":"person_unknown_identity","id":"passenger_request","onWrongFlags":["invalid_request_allowed"],"roundType":"identity","trigger":"floor_flash_ignored_or_next_shift"},{"contentId":"space_floor_13","id":"impossible_space","onWrongFlags":["floor_chain_compromised"],"roundType":"highRisk","trigger":"invalid_request_allowed_or_escalation"}]},{"consequences":[{"contaminationDelta":20,"flag":"camera_chain_compromised","nextShiftModifier":"unreliable_cam07"}],"id":"camera_replacement","initialFlags":[],"steps":[{"contentId":"time_delay_overrun","id":"cam07_delay","onWrongFlags":["delay_accepted"],"roundType":"investigation","trigger":"cam07_delay"},{"contentId":"time_clock_stall","id":"time_stops","onWrongFlags":["clock_stop_ignored"],"roundType":"investigation","trigger":"delay_accepted_or_next_shift"},{"contentId":"device_camera_substitution","id":"feed_replaced","onWrongFlags":["camera_chain_compromised"],"roundType":"highRisk","trigger":"clock_stop_ignored_or_escalation"}]}],"normalShifts":[{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_01_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_01_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_01_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_01","panelData":{"door":"closed","floor":2,"passengers":1},"passengerIds":["resident_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":2,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_02_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_02_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_02_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_02","panelData":{"door":"closed","floor":3,"passengers":1},"passengerIds":["worker_001"],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":3,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_03_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_03_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_03_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_03","panelData":{"door":"open","floor":4,"passengers":0},"passengerIds":[],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":4,"passengers":0}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_04_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_04_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_04_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_04","panelData":{"door":"closed","floor":5,"passengers":1},"passengerIds":["cleaner_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_05_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_05_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_05_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_05","panelData":{"door":"closed","floor":6,"passengers":1},"passengerIds":["security_001"],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":6,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_06_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_06_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_06_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_06","panelData":{"door":"open","floor":7,"passengers":1},"passengerIds":["resident_001"],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":7,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_07_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_07_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_07_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_07","panelData":{"door":"closed","floor":8,"passengers":1},"passengerIds":["worker_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_08_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_08_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_08_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_08","panelData":{"door":"closed","floor":9,"passengers":0},"passengerIds":[],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":9,"passengers":0}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_09_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_09_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_09_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_09","panelData":{"door":"open","floor":10,"passengers":1},"passengerIds":["cleaner_001"],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":10,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_10_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_10_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_10_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_10","panelData":{"door":"closed","floor":11,"passengers":1},"passengerIds":["security_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":11,"passengers":1}}],"passengers":[{"allowedFloors":["B2","8"],"badge":"yellow","countMode":"ignore","id":"worker_001","name":"张伟","role":"maintenance","verificationPaths":["cam01","protocol"]},{"allowedFloors":["3","6","9"],"badge":"blue","countMode":"normal","id":"resident_001","name":"林岚","role":"resident","verificationPaths":["cam01","registry"]},{"allowedFloors":["1","2","3"],"badge":"orange","countMode":"normal","id":"courier_001","name":"陈杰","role":"courier","verificationPaths":["cam03","registry"]},{"allowedFloors":["2","4","6","8","10","12"],"badge":"green","countMode":"normal","id":"cleaner_001","name":"赵梅","role":"cleaner","verificationPaths":["cam01","protocol"]},{"allowedFloors":["B2","1","13"],"badge":"white","countMode":"normal","id":"security_001","name":"周启","role":"security","verificationPaths":["cam01","protocol"]}],"protocols":[{"category":"floor","condition":{"field":"floor","operator":"equals","value":13},"decision":"lockdown","id":"floor_13_forbidden","protocolTags":["floor"],"text":"13 层不存在,任何前往 13 层的请求必须封锁。","verificationPaths":["protocol","cam07"]},{"category":"identity","condition":{"field":"identity","operator":"equals","value":"maintenance_without_yellow_badge"},"decision":"lockdown","id":"maintenance_badge_required","protocolTags":["identity","personnel"],"text":"维修人员不计入人数,但必须佩戴黄色胸牌。","verificationPaths":["cam01","thermal"]},{"category":"device","condition":{"field":"cameraDelayMs","operator":"lte","value":2000},"decision":"release","id":"cam07_delay_expected","protocolTags":["device"],"text":"CAM-07 固定延迟两秒,延迟本身不属于异常。","verificationPaths":["cam01","replay"]},{"category":"time","condition":{"field":"cleanerOddFloorAfterMidnight","operator":"truthy"},"decision":"lockdown","id":"cleaner_after_midnight","protocolTags":["time","personnel"],"text":"零点后清洁人员只能前往偶数楼层。","verificationPaths":["protocol","cam01"]},{"category":"personnel","condition":{"field":"emptyWeightDelayCount","operator":"gte","value":2},"decision":"lockdown","id":"empty_car_weight_tolerance","protocolTags":["personnel","device"],"text":"空轿厢允许一次载重计数延迟,但第二次必须封锁。","verificationPaths":["cam01","replay"]},{"category":"device","condition":{"field":"maintenanceDoorCycles","operator":"lte","value":1},"decision":"release","id":"door_cycle_maintenance","protocolTags":["device"],"text":"维护灯亮起时允许一次门状态循环。","verificationPaths":["cam01","cam03"]}]};
+
// --- src/gameConfig.js ---
+var __exports_src_gameConfig_js = {};
+{
/**
* gameConfig.js — MINIGAME 平衡参数配置(单一配置源)
*
@@ -135,11 +140,16 @@ const CONFIG = {
CONFIG;
+__exports_src_gameConfig_js["CONFIG"] = CONFIG;
+}
+var CONFIG = __exports_src_gameConfig_js["CONFIG"];
// --- src/skins/elevator/skin.json ---
-var __SKIN_DATA__ = {"meta":{"id":"elevator","name":"异常电梯控制台","subtitle":"MINIGAME · ANOMALY SYSTEM SIM"},"monitor":{"initial":"监控画面稳定:1 层轿厢为空。","actions":{"openDoor":"监控:{floor} 层电梯门已打开。门外走廊光线异常。","closeDoor":"监控:轿厢门闭合。画面存在轻微拖影。","moveUp":"监控:电梯上行至 {floor} 层。乘客未看向摄像头。","moveDown":"监控:电梯下行至 {floor} 层。楼层指示灯短暂闪烁。","emergencyStop":"监控:电梯急停。轿厢灯光闪烁 3 次。","restartSystem":"监控:系统重启后恢复画面。部分录像帧丢失。"}},"actionLabels":{"openDoor":"开门","closeDoor":"关门","moveUp":"上行","moveDown":"下行","emergencyStop":"急停","restartSystem":"系统重启","inspectLog":"查看日志","unlockHiddenLog":"解码加密记录"},"doorLabels":{"open":"开启","closed":"关闭"},"directionLabels":{"up":"上行","down":"下行","idle":"待机"},"statusLabels":{"panelTitle":"电梯状态","floor":"楼层","door":"门状态","direction":"方向","passengers":"乘客","power":"电源","stability":"稳定度","anomalyLevel":"异常等级","reviveCount":"广告复活","adHintsCount":"加密解码","hiddenLogsCount":"待解码"},"canvasLabels":{"countdown":"值守倒计时","monitorPanel":"监控画面","actionPanel":"操作面板","logPanel":"系统日志","failureTitle":"系统崩溃","failureEyebrow":"SYSTEM FAILURE","monitorSignalStable":"SYSTEM: STABLE","monitorSignalUnstable":"SYSTEM: UNSTABLE","monitorSignalCorrupted":"SYSTEM: CORRUPTED","monitorThreat":"THREAT: {level}","failureMetricStability":"稳定度","failureMetricAnomaly":"异常","failureMetricRemaining":"剩余"},"actionFailMessages":{"openDoor_moving":"电梯移动中,禁止开门。","moveUp_doorNotClosed":"门未关闭,禁止移动。","moveDown_doorNotClosed":"门未关闭,禁止移动。","unknownAction":"未知操作:{actionId}","gameOver":"系统已崩溃,必须复活或重新开始。","systemBusy":"当前动作尚未完成,请等待电梯状态稳定。"},"actionFeedback":{"openDoor":"电梯门已打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行。","moveDown":"电梯开始下行。","emergencyStop":"急停已执行。","emergencyStop_fail":"急停按钮失效。","restartSystem":"系统重启完成。","inspectLog":"已查看系统日志。","unlockHiddenLog_noLocked":"没有待解码的加密记录。","unlockHiddenLog_limit":"本局已解码 {count} 条记录,达到上限。"},"actionLogMessages":{"openDoor":"电梯门已在 {floor} 层打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行,当前楼层 {floor}。","moveDown":"电梯开始下行,当前楼层 {floor}。","emergencyStop":"执行急停:移动已停止,稳定度下降。","emergencyStop_fail":"急停按钮无响应。异常等级上升。","restartSystem":"系统重启完成:异常等级下降,但消耗 {cost} 点电源。","inspectLog":"操作员查看系统日志:最近 30 秒存在未授权楼层请求。","inspectLog_hiddenRecords":"发现 {count} 条待解码加密记录。可观看模拟广告解锁完整内容。","unlockHiddenLog_ok":"模拟广告播放完成。加密记录已解码。"},"anomalies":[{"id":"phantom_floor","title":"不存在的楼层","severity":2,"monitor":"监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。","adHint":"楼层显示异常时不要开门,先执行系统重启。","effects":{"floor":"+2","anomalyLevel":2,"stability":-10}},{"id":"camera_delay","title":"监控延迟","severity":1,"monitor":"监控:画面延迟 3 秒。乘客动作与控制台记录不同步。","adHint":"监控延迟时优先查看日志,不要连续移动。","effects":{"anomalyLevel":1,"stability":-6}},{"id":"zero_passenger_shadow","title":"门外有人但乘客数为 0","severity":2,"monitor":"监控:门外站着一个人,但乘客计数器显示 0。","adHint":"乘客数异常时保持关门,先急停再查日志。","effects":{"passengers":0,"anomalyLevel":2,"stability":-12}},{"id":"log_echo","title":"系统日志重复字符","severity":1,"monitor":"监控:系统日志开始重复输出“不要开门”。","adHint":"日志重复通常是轻度异常,系统重启可降低异常等级。","effects":{"anomalyLevel":1,"stability":-5}},{"id":"auto_button","title":"按钮自动亮起","severity":2,"monitor":"监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。","adHint":"按钮自动亮起时不要跟随请求移动,先关门并急停。","effects":{"anomalyLevel":2,"power":-8}},{"id":"stop_failure","title":"急停按钮失效","severity":3,"monitor":"监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。","adHint":"急停失效时不要反复点击,优先系统重启。","effects":{"anomalyLevel":3,"stability":-15}},{"id":"negative_floor","title":"楼层显示为负数","severity":2,"monitor":"监控:楼层显示 -1。摄像头画面出现地下走廊。","adHint":"负数楼层不是正常地下层,立即重启系统。","effects":{"floor":-1,"anomalyLevel":2,"stability":-10}},{"id":"power_drain","title":"电源异常下降","severity":2,"monitor":"监控:备用电源自动接管,但电量仍在下降。","adHint":"电源异常下降时减少移动,优先关门与重启。","effects":{"anomalyLevel":2,"power":-22}},{"id":"door_refuse","title":"电梯门拒绝关闭","severity":2,"monitor":"监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。","adHint":"门拒绝关闭时不要连续按关门,先急停再重启系统。","effects":{"door":"open","anomalyLevel":2,"stability":-10}},{"id":"weight_mismatch","title":"载重数据异常","severity":1,"monitor":"监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。","adHint":"载重异常时优先查日志,乘客数可能被重置。","effects":{"passengers":0,"anomalyLevel":1,"stability":-7}},{"id":"floor_jump","title":"楼层编号跳跃","severity":2,"monitor":"监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。","adHint":"楼层跳跃时减少移动操作,用系统重启恢复楼层显示。","effects":{"floor":"+4","anomalyLevel":2,"stability":-12,"power":-10}},{"id":"emergency_lights","title":"应急灯异常启动","severity":3,"monitor":"监控:轿厢应急灯突然亮起。备用电源消耗加速。","adHint":"应急灯启动时尽量避免移动,立即重启系统可关闭应急灯。","effects":{"anomalyLevel":3,"stability":-14,"power":-20}}],"hiddenLogs":{"phantom_floor":{"title":"未归档楼层施工记录","content":"施工记录(编号模糊):存在未归档的夹层结构,位于正常楼层之间。\\n档案中未找到该夹层的施工许可或验收记录。\\n控制面板能收到来自该夹层的按钮信号,尽管物理按钮不存在于任何楼层。\\n技术人员备注:该信号可能与 3 年前失踪的 3 名工人有关。"},"camera_delay":{"title":"监控系统校准记录","content":"校准日志 #4417:摄像头#03 与#07 存在 3 秒信号延迟。\n技术人员备注:延迟与第 13 层信号干扰有关,建议不要在 13 层停靠。"},"zero_passenger_shadow":{"title":"乘客记录异常说明","content":"传感器技术手册(节选):\n红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。\n维修记录:传感器无故障。热源信号经比对——与员工体温档案不匹配。"},"log_echo":{"title":"日志系统诊断报告","content":"诊断报告 #FD-22-019:\n系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间。\n建议:检查前一值班员的退出状态。"},"auto_button":{"title":"控制系统审计追踪","content":"审计追踪 #AUD-882:\n自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。\n该服务器的最后一条记录:「控制权移交程序未完成」。"},"stop_failure":{"title":"急停系统维护日志","content":"维护日志 #M-341:\n急停回路#2 在定期检查中被标记为「状态:不可用」。\n签署人签名无法识别。签署时间:3 年前。没有后续维修记录。"},"negative_floor":{"title":"地下层勘测报告","content":"建筑勘测报告(内部):\n地下实际存在 4 层结构,但公开图纸仅标注 B1-B2。\nB3-B4 的电梯按钮在出厂时已被移除,但线路仍然通电。"},"power_drain":{"title":"备用电源异常报告","content":"异常报告 #P-877:\n备用电源在无负载状态下持续放电。经查,有一条非授权线路从备用电源柜分接至未知设备。\n线路标签:「不要切断」。"},"door_refuse":{"title":"门控系统事故报告","content":"事故报告 #D-1290:\n门控模块在连续 3 次异常重启后进入保护模式。\n模块日志输出最后一条:「识别到外部干扰信号。拒绝执行 — 保护乘员安全」。"},"weight_mismatch":{"title":"传感器校验记录","content":"校验记录 #W-554:\n载重传感器与红外传感器读数不一致。红外传感器在轿厢空载时检测到热源。\n技术人员备注:请确认值班员在操作前已清空轿厢。"},"floor_jump":{"title":"楼层定位日志","content":"定位日志 #F-213:\nGPS 楼层定位模块在校准前后记录的楼层编号不一致。\n系统自动修正失败。可能原因:参考信号源来自非标设备。"},"emergency_lights":{"title":"应急照明测试报告","content":"测试报告 #E-777:\n应急照明系统在无触发信号的情况下自行启动。\n供电线路检测到寄生回路。回路终端设备编号无法匹配任何已知设备清单。"}},"failure":{"summaries":{"power":"电源耗尽","stability":"稳定度归零","anomalyLevel":"异常等级失控","passengers":"乘客记录出现负数","default":"系统拒绝继续响应"},"defaultHint":"先关门,再重启系统,避免连续移动。","firstRunAdvice":"下次先核对画面、楼层、人数和门状态;一致放行,矛盾封锁。","adHintPrefix":"广告提示:{hint}","adReviveRollback":"广告复活完成:回滚 {seconds} 秒,恢复至可控状态。","adReviveMonitor":"广告复活完成:回滚到 {seconds} 秒前的系统状态。","snapshotFallback":"可观看广告复活,回滚到 {seconds} 秒前的系统状态。","noSnapshotFallback":"可观看广告复活,回滚到初始系统状态。"},"fakeEnding":{"eyebrow":"⚠ SYSTEM ANOMALY DETECTED","title":"操作员关联异常","text":"系统检测到操作员第 {count} 次系统崩溃。\n根据《异常控制员守则》第 7 条,您已被标记为“异常关联人员”。\n前 {threshold} 次记录已被永久删除。\n建议您立即离开控制台并联系安保部门。","truthPlaceholder":"[???] 观看广告揭示真相。","truthContent":"这不是第一次,也不会是最后一次。\n这座建筑的异常系统从未被修复。\n每一任值班员最后都变成了「异常事件」本身。\n系统日志中关于「乘客」的记载——都是前任值班员的热源信号。\n你现在坐的位置,就是上一任值班员被发现的地方。"},"ui":{"viewAd":"观看广告复活","unlockAd":"解码加密记录","restart":"重新开始","revealTruth":"观看广告揭示真相","triggerTest":"触发异常测试","decodePrefix":"[解码记录]","initialLog":"异常电梯控制台已接管。等待操作员指令。","initialFeedback":"等待下一班电梯","tutorialNormal":"信息一致,点击放行","tutorialAnomaly":"发现矛盾,点击封锁","coreRule":"核对画面和数据:一致放行,矛盾封锁","standby":"等待下一班","wrongTutorial":"再看一眼:核对楼层、人数和门状态","wrongTreatment":"处置错误,异常仍在持续。","inspectionReady":"请核对当前画面和三项数据","treatmentTutorial":"最后一步:按亮起的处置键解除异常","wrongTreatmentTutorial":"这项处置不对应当前线索,再看一次","autoResolutionCorrect":"封锁成功,系统已自动处置","autoResolutionWrong":"判断错误,系统已紧急隔离","autoResolutionTimeout":"判断超时,系统已自动隔离","anomalyEventLog":"异常事件:{title}。{hint}","startTitle":"等待接管异常电梯","startCopy":"核对楼层、人数和门状态:对得上就放行,对不上就封锁。前两班会在实际画面中教会你。","startChecklist":"三项一致:放行\n任意一项矛盾:封锁\n前两班点错不会扣分","startFailureRulesTitle":"失败条件","startFailureRules":"电源归零\n稳定度归零\n异常等级失控","startButton":"开始接管","sidebarEntry":"侧边栏入口","pausedTitle":"值守已暂停","pausedCopy":"返回前台后继续,不计算后台时间","audioOn":"声音开","audioOff":"已静音","adUnavailable":"广告暂不可用,请稍后重试","reportNormal":"放行","reportAnomaly":"封锁","inspectionLabel":"请在 {seconds}s 内判断","baselineInspectionTitle":"核对画面与数据","anomalyInspectionTitle":"核对画面与数据","anomalyResolved":"处置完成:{action} 已解除当前异常。","anomalyResolvedMonitor":"监控恢复稳定,等待下一轮巡检。","inspectionPrompt":"巡检判定:{title}({seconds}秒内响应)","inspectionCorrectNormal":"判定正确:当前画面正常。","inspectionCorrectAnomaly":"判定正确:异常已上报,系统压力下降。","inspectionWrong":"判定错误:稳定度下降,异常压力上升。","inspectionTimeout":"判定超时:未完成本次巡检。","successfulShift":"本轮结束,连续失败计数已重置。","shiftComplete":"值守完成","hiddenLogCaptured":"加密记录已捕获:{title}。使用「查看日志」功能解码。","unlockResult":"已解码:{title}","decodeMonitor":"解码完成:{title}。完整内容已写入系统日志。"}};
+var __SKIN_DATA__ = {"meta":{"id":"elevator","name":"异常电梯控制台","subtitle":"MINIGAME · ANOMALY SYSTEM SIM"},"monitor":{"initial":"监控画面稳定:1 层轿厢为空。","actions":{"openDoor":"监控:{floor} 层电梯门已打开。门外走廊光线异常。","closeDoor":"监控:轿厢门闭合。画面存在轻微拖影。","moveUp":"监控:电梯上行至 {floor} 层。乘客未看向摄像头。","moveDown":"监控:电梯下行至 {floor} 层。楼层指示灯短暂闪烁。","emergencyStop":"监控:电梯急停。轿厢灯光闪烁 3 次。","restartSystem":"监控:系统重启后恢复画面。部分录像帧丢失。"}},"actionLabels":{"openDoor":"开门","closeDoor":"关门","moveUp":"上行","moveDown":"下行","emergencyStop":"急停","restartSystem":"系统重启","inspectLog":"查看日志","unlockHiddenLog":"解码加密记录"},"doorLabels":{"open":"开启","closed":"关闭"},"directionLabels":{"up":"上行","down":"下行","idle":"待机"},"statusLabels":{"panelTitle":"电梯状态","floor":"楼层","door":"门状态","direction":"方向","passengers":"乘客","power":"电源","stability":"稳定度","anomalyLevel":"异常等级","reviveCount":"广告复活","adHintsCount":"加密解码","hiddenLogsCount":"待解码"},"canvasLabels":{"countdown":"值守倒计时","monitorPanel":"监控画面","actionPanel":"操作面板","logPanel":"系统日志","failureTitle":"系统崩溃","failureEyebrow":"系统故障","monitorSignalStable":"SYSTEM: STABLE","monitorSignalUnstable":"SYSTEM: UNSTABLE","monitorSignalCorrupted":"SYSTEM: CORRUPTED","monitorThreat":"THREAT: {level}","failureMetricStability":"稳定度","failureMetricAnomaly":"异常","failureMetricRemaining":"剩余"},"actionFailMessages":{"openDoor_moving":"电梯移动中,禁止开门。","moveUp_doorNotClosed":"门未关闭,禁止移动。","moveDown_doorNotClosed":"门未关闭,禁止移动。","unknownAction":"未知操作:{actionId}","gameOver":"系统已崩溃,必须复活或重新开始。","systemBusy":"当前动作尚未完成,请等待电梯状态稳定。"},"actionFeedback":{"openDoor":"电梯门已打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行。","moveDown":"电梯开始下行。","emergencyStop":"急停已执行。","emergencyStop_fail":"急停按钮失效。","restartSystem":"系统重启完成。","inspectLog":"已查看系统日志。","unlockHiddenLog_noLocked":"没有待解码的加密记录。","unlockHiddenLog_limit":"本局已解码 {count} 条记录,达到上限。"},"actionLogMessages":{"openDoor":"电梯门已在 {floor} 层打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行,当前楼层 {floor}。","moveDown":"电梯开始下行,当前楼层 {floor}。","emergencyStop":"执行急停:移动已停止,稳定度下降。","emergencyStop_fail":"急停按钮无响应。异常等级上升。","restartSystem":"系统重启完成:异常等级下降,但消耗 {cost} 点电源。","inspectLog":"操作员查看系统日志:最近 30 秒存在未授权楼层请求。","inspectLog_hiddenRecords":"发现 {count} 条待解码加密记录。可观看模拟广告解锁完整内容。","unlockHiddenLog_ok":"模拟广告播放完成。加密记录已解码。"},"anomalies":[{"id":"phantom_floor","title":"不存在的楼层","severity":2,"monitor":"监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。","adHint":"楼层显示异常时不要开门,先执行系统重启。","effects":{"floor":"+2","anomalyLevel":2,"stability":-10}},{"id":"camera_delay","title":"监控延迟","severity":1,"monitor":"监控:画面延迟 3 秒。乘客动作与控制台记录不同步。","adHint":"监控延迟时优先查看日志,不要连续移动。","effects":{"anomalyLevel":1,"stability":-6}},{"id":"zero_passenger_shadow","title":"门外有人但乘客数为 0","severity":2,"monitor":"监控:门外站着一个人,但乘客计数器显示 0。","adHint":"乘客数异常时保持关门,先急停再查日志。","effects":{"passengers":0,"anomalyLevel":2,"stability":-12}},{"id":"log_echo","title":"系统日志重复字符","severity":1,"monitor":"监控:系统日志开始重复输出“不要开门”。","adHint":"日志重复通常是轻度异常,系统重启可降低异常等级。","effects":{"anomalyLevel":1,"stability":-5}},{"id":"auto_button","title":"按钮自动亮起","severity":2,"monitor":"监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。","adHint":"按钮自动亮起时不要跟随请求移动,先关门并急停。","effects":{"anomalyLevel":2,"power":-8}},{"id":"stop_failure","title":"急停按钮失效","severity":3,"monitor":"监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。","adHint":"急停失效时不要反复点击,优先系统重启。","effects":{"anomalyLevel":3,"stability":-15}},{"id":"negative_floor","title":"楼层显示为负数","severity":2,"monitor":"监控:楼层显示 -1。摄像头画面出现地下走廊。","adHint":"负数楼层不是正常地下层,立即重启系统。","effects":{"floor":-1,"anomalyLevel":2,"stability":-10}},{"id":"power_drain","title":"电源异常下降","severity":2,"monitor":"监控:备用电源自动接管,但电量仍在下降。","adHint":"电源异常下降时减少移动,优先关门与重启。","effects":{"anomalyLevel":2,"power":-22}},{"id":"door_refuse","title":"电梯门拒绝关闭","severity":2,"monitor":"监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。","adHint":"门拒绝关闭时不要连续按关门,先急停再重启系统。","effects":{"door":"open","anomalyLevel":2,"stability":-10}},{"id":"weight_mismatch","title":"载重数据异常","severity":1,"monitor":"监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。","adHint":"载重异常时优先查日志,乘客数可能被重置。","effects":{"passengers":0,"anomalyLevel":1,"stability":-7}},{"id":"floor_jump","title":"楼层编号跳跃","severity":2,"monitor":"监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。","adHint":"楼层跳跃时减少移动操作,用系统重启恢复楼层显示。","effects":{"floor":"+4","anomalyLevel":2,"stability":-12,"power":-10}},{"id":"emergency_lights","title":"应急灯异常启动","severity":3,"monitor":"监控:轿厢应急灯突然亮起。备用电源消耗加速。","adHint":"应急灯启动时尽量避免移动,立即重启系统可关闭应急灯。","effects":{"anomalyLevel":3,"stability":-14,"power":-20}}],"hiddenLogs":{"phantom_floor":{"title":"未归档楼层施工记录","content":"施工记录(编号模糊):存在未归档的夹层结构,位于正常楼层之间。\\n档案中未找到该夹层的施工许可或验收记录。\\n控制面板能收到来自该夹层的按钮信号,尽管物理按钮不存在于任何楼层。\\n技术人员备注:该信号可能与 3 年前失踪的 3 名工人有关。"},"camera_delay":{"title":"监控系统校准记录","content":"校准日志 #4417:摄像头#03 与#07 存在 3 秒信号延迟。\n技术人员备注:延迟与第 13 层信号干扰有关,建议不要在 13 层停靠。"},"zero_passenger_shadow":{"title":"乘客记录异常说明","content":"传感器技术手册(节选):\n红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。\n维修记录:传感器无故障。热源信号经比对——与员工体温档案不匹配。"},"log_echo":{"title":"日志系统诊断报告","content":"诊断报告 #FD-22-019:\n系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间。\n建议:检查前一值班员的退出状态。"},"auto_button":{"title":"控制系统审计追踪","content":"审计追踪 #AUD-882:\n自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。\n该服务器的最后一条记录:「控制权移交程序未完成」。"},"stop_failure":{"title":"急停系统维护日志","content":"维护日志 #M-341:\n急停回路#2 在定期检查中被标记为「状态:不可用」。\n签署人签名无法识别。签署时间:3 年前。没有后续维修记录。"},"negative_floor":{"title":"地下层勘测报告","content":"建筑勘测报告(内部):\n地下实际存在 4 层结构,但公开图纸仅标注 B1-B2。\nB3-B4 的电梯按钮在出厂时已被移除,但线路仍然通电。"},"power_drain":{"title":"备用电源异常报告","content":"异常报告 #P-877:\n备用电源在无负载状态下持续放电。经查,有一条非授权线路从备用电源柜分接至未知设备。\n线路标签:「不要切断」。"},"door_refuse":{"title":"门控系统事故报告","content":"事故报告 #D-1290:\n门控模块在连续 3 次异常重启后进入保护模式。\n模块日志输出最后一条:「识别到外部干扰信号。拒绝执行 — 保护乘员安全」。"},"weight_mismatch":{"title":"传感器校验记录","content":"校验记录 #W-554:\n载重传感器与红外传感器读数不一致。红外传感器在轿厢空载时检测到热源。\n技术人员备注:请确认值班员在操作前已清空轿厢。"},"floor_jump":{"title":"楼层定位日志","content":"定位日志 #F-213:\nGPS 楼层定位模块在校准前后记录的楼层编号不一致。\n系统自动修正失败。可能原因:参考信号源来自非标设备。"},"emergency_lights":{"title":"应急照明测试报告","content":"测试报告 #E-777:\n应急照明系统在无触发信号的情况下自行启动。\n供电线路检测到寄生回路。回路终端设备编号无法匹配任何已知设备清单。"}},"failure":{"summaries":{"power":"电源耗尽","stability":"稳定度归零","anomalyLevel":"异常等级失控","passengers":"乘客记录出现负数","default":"系统拒绝继续响应"},"defaultHint":"先关门,再重启系统,避免连续移动。","firstRunAdvice":"下次先核对画面、楼层、人数和门状态;一致放行,矛盾封锁。","adHintPrefix":"广告提示:{hint}","adReviveRollback":"广告复活完成:回滚 {seconds} 秒,恢复至可控状态。","adReviveMonitor":"广告复活完成:回滚到 {seconds} 秒前的系统状态。","snapshotFallback":"可观看广告复活,回滚到 {seconds} 秒前的系统状态。","noSnapshotFallback":"可观看广告复活,回滚到初始系统状态。"},"fakeEnding":{"eyebrow":"⚠ SYSTEM ANOMALY DETECTED","title":"操作员关联异常","text":"系统检测到操作员第 {count} 次系统崩溃。\n根据《异常控制员守则》第 7 条,您已被标记为“异常关联人员”。\n前 {threshold} 次记录已被永久删除。\n建议您立即离开控制台并联系安保部门。","truthPlaceholder":"[???] 观看广告揭示真相。","truthContent":"这不是第一次,也不会是最后一次。\n这座建筑的异常系统从未被修复。\n每一任值班员最后都变成了「异常事件」本身。\n系统日志中关于「乘客」的记载——都是前任值班员的热源信号。\n你现在坐的位置,就是上一任值班员被发现的地方。"},"ui":{"viewAd":"观看广告复活","unlockAd":"解码加密记录","restart":"重新开始","revealTruth":"观看广告揭示真相","triggerTest":"触发异常测试","decodePrefix":"[解码记录]","initialLog":"异常电梯控制台已接管。等待操作员指令。","initialFeedback":"等待下一班电梯","tutorialNormal":"信息一致,点击放行","tutorialAnomaly":"发现矛盾,点击封锁","coreRule":"核对画面和数据:一致放行,矛盾封锁","standby":"等待下一班","wrongTutorial":"再看一眼:核对楼层、人数和门状态","wrongTreatment":"处置错误,异常仍在持续。","inspectionReady":"请核对当前画面和三项数据","treatmentTutorial":"最后一步:按亮起的处置键解除异常","wrongTreatmentTutorial":"这项处置不对应当前线索,再看一次","autoResolutionCorrect":"封锁成功,系统已自动处置","autoResolutionWrong":"判断错误,系统已紧急隔离","autoResolutionTimeout":"判断超时,系统已自动隔离","anomalyEventLog":"异常事件:{title}。{hint}","startTitle":"等待接管异常电梯","startCopy":"核对楼层、人数和门状态:对得上就放行,对不上就封锁。前两班会在实际画面中教会你。","startChecklist":"三项一致:放行\n任意一项矛盾:封锁\n前两班点错不会扣分","startFailureRulesTitle":"失败条件","startFailureRules":"电源归零\n稳定度归零\n异常等级失控","startButton":"开始接管","sidebarEntry":"侧边栏入口","pausedTitle":"值守已暂停","pausedCopy":"返回前台后继续,不计算后台时间","audioOn":"声音开","audioOff":"已静音","adUnavailable":"广告暂不可用,请稍后重试","reportNormal":"放行","reportAnomaly":"封锁","inspectionLabel":"请在 {seconds}s 内判断","baselineInspectionTitle":"核对画面与数据","anomalyInspectionTitle":"核对画面与数据","anomalyResolved":"处置完成:{action} 已解除当前异常。","anomalyResolvedMonitor":"监控恢复稳定,等待下一轮巡检。","inspectionPrompt":"巡检判定:{title}({seconds}秒内响应)","inspectionCorrectNormal":"判定正确:当前画面正常。","inspectionCorrectAnomaly":"判定正确:异常已上报,系统压力下降。","inspectionWrong":"判定错误:稳定度下降,异常压力上升。","inspectionTimeout":"判定超时:未完成本次巡检。","successfulShift":"本轮结束,连续失败计数已重置。","shiftComplete":"值守完成","hiddenLogCaptured":"加密记录已捕获:{title}。使用「查看日志」功能解码。","unlockResult":"已解码:{title}","decodeMonitor":"解码完成:{title}。完整内容已写入系统日志。"}};
// --- src/skinManager.js ---
+var __exports_src_skinManager_js = {};
+{
/**
* skinManager.js — 换皮系统核心
*
@@ -247,8 +257,29 @@ function actionLabel(actionId, count) {
return label;
}
+__exports_src_skinManager_js["loadSkin"] = loadSkin;
+__exports_src_skinManager_js["getSkin"] = getSkin;
+__exports_src_skinManager_js["t"] = t;
+__exports_src_skinManager_js["getAnomalies"] = getAnomalies;
+__exports_src_skinManager_js["getAnomaly"] = getAnomaly;
+__exports_src_skinManager_js["getHiddenLog"] = getHiddenLog;
+__exports_src_skinManager_js["applyEffects"] = applyEffects;
+__exports_src_skinManager_js["actionText"] = actionText;
+__exports_src_skinManager_js["actionLabel"] = actionLabel;
+}
+var loadSkin = __exports_src_skinManager_js["loadSkin"];
+var getSkin = __exports_src_skinManager_js["getSkin"];
+var t = __exports_src_skinManager_js["t"];
+var getAnomalies = __exports_src_skinManager_js["getAnomalies"];
+var getAnomaly = __exports_src_skinManager_js["getAnomaly"];
+var getHiddenLog = __exports_src_skinManager_js["getHiddenLog"];
+var applyEffects = __exports_src_skinManager_js["applyEffects"];
+var actionText = __exports_src_skinManager_js["actionText"];
+var actionLabel = __exports_src_skinManager_js["actionLabel"];
// --- src/rollback.js ---
+var __exports_src_rollback_js = {};
+{
function findRollbackSnapshot(snapshots, elapsed) {
if (!snapshots || snapshots.length === 0) return null;
@@ -265,8 +296,13 @@ function findRollbackSnapshot(snapshots, elapsed) {
return best;
}
+__exports_src_rollback_js["findRollbackSnapshot"] = findRollbackSnapshot;
+}
+var findRollbackSnapshot = __exports_src_rollback_js["findRollbackSnapshot"];
// --- src/feedback.js ---
+var __exports_src_feedback_js = {};
+{
function classifyFeedbackPriority(type) {
@@ -323,8 +359,676 @@ function getToneForState(state) {
return 'normal';
}
+__exports_src_feedback_js["classifyFeedbackPriority"] = classifyFeedbackPriority;
+__exports_src_feedback_js["createFeedbackLine"] = createFeedbackLine;
+__exports_src_feedback_js["summarizeFailure"] = summarizeFailure;
+__exports_src_feedback_js["getToneForState"] = getToneForState;
+}
+var classifyFeedbackPriority = __exports_src_feedback_js["classifyFeedbackPriority"];
+var createFeedbackLine = __exports_src_feedback_js["createFeedbackLine"];
+var summarizeFailure = __exports_src_feedback_js["summarizeFailure"];
+var getToneForState = __exports_src_feedback_js["getToneForState"];
+
+// --- src/protocolEngine.js ---
+var __exports_src_protocolEngine_js = {};
+{
+function compare(value, operator, expected) {
+ if (operator === 'equals') return value === expected;
+ if (operator === 'lte') return Number(value) <= Number(expected);
+ if (operator === 'gte') return Number(value) >= Number(expected);
+ if (operator === 'truthy') return Boolean(value);
+ return false;
+}
+
+function protocolAppliesToShift(protocol, shift = {}) {
+ const tags = new Set(shift.protocolTags || []);
+ return (protocol.protocolTags || []).some(tag => tags.has(tag));
+}
+
+function evaluateProtocolDecision(protocol, shift = {}) {
+ const condition = protocol?.condition || {};
+ const observed = shift.screenData?.[condition.field]
+ ?? shift.panelData?.[condition.field]
+ ?? shift.evidence?.[condition.field];
+ const matched = compare(observed, condition.operator, condition.value);
+ const violated = protocol?.decision === 'lockdown' ? matched : !matched;
+ return {
+ violated,
+ decision: violated ? 'lockdown' : 'release',
+ observed,
+ expected: condition.value,
+ verificationPaths: [...(protocol?.verificationPaths || [])],
+ };
+}
+
+function evaluateNightProtocolSet(protocols = [], shift = {}) {
+ const applied = protocols.filter(protocol => protocolAppliesToShift(protocol, shift));
+ const results = applied.map(protocol => ({ protocol, result: evaluateProtocolDecision(protocol, shift) }));
+ const violated = results.filter(item => item.result.violated);
+ return {
+ decision: violated.length ? 'lockdown' : 'release',
+ appliedProtocolIds: applied.map(protocol => protocol.id),
+ violatedProtocolIds: violated.map(item => item.protocol.id),
+ verificationPaths: [...new Set(results.flatMap(item => item.result.verificationPaths))].sort(),
+ };
+}
+
+function generateNightProtocols({ protocols = [], shifts = [], count = 2, random = Math.random } = {}) {
+ const target = Math.max(2, Math.min(3, Math.trunc(count || 2)));
+ const applicable = protocols.filter(protocol => shifts.some(shift => protocolAppliesToShift(protocol, shift)));
+ const selected = [];
+ if (applicable.length) selected.push(applicable[Math.floor(random() * applicable.length) % applicable.length]);
+ const remaining = protocols.filter(protocol => !selected.some(item => item.id === protocol.id));
+ while (selected.length < target && remaining.length) {
+ const index = Math.floor(random() * remaining.length) % remaining.length;
+ selected.push(remaining.splice(index, 1)[0]);
+ }
+ return selected;
+}
+
+__exports_src_protocolEngine_js["protocolAppliesToShift"] = protocolAppliesToShift;
+__exports_src_protocolEngine_js["evaluateProtocolDecision"] = evaluateProtocolDecision;
+__exports_src_protocolEngine_js["evaluateNightProtocolSet"] = evaluateNightProtocolSet;
+__exports_src_protocolEngine_js["generateNightProtocols"] = generateNightProtocols;
+}
+var protocolAppliesToShift = __exports_src_protocolEngine_js["protocolAppliesToShift"];
+var evaluateProtocolDecision = __exports_src_protocolEngine_js["evaluateProtocolDecision"];
+var evaluateNightProtocolSet = __exports_src_protocolEngine_js["evaluateNightProtocolSet"];
+var generateNightProtocols = __exports_src_protocolEngine_js["generateNightProtocols"];
+
+// --- src/evidenceEngine.js ---
+var __exports_src_evidenceEngine_js = {};
+{
+const CORE_FIELDS = Object.freeze(['floor', 'passengers', 'door']);
+const FIELD_LABELS = Object.freeze({ floor: '楼层', passengers: '人数', door: '门状态' });
+
+function compareCoreEvidence(screenData = {}, panelData = {}) {
+ return CORE_FIELDS
+ .filter(field => screenData[field] !== panelData[field])
+ .map(field => ({ field, screen: screenData[field], panel: panelData[field] }));
+}
+
+function evaluateEvidence({ screenData = {}, panelData = {}, protocolResult = null } = {}) {
+ const conflicts = compareCoreEvidence(screenData, panelData);
+ if (protocolResult?.violated) {
+ conflicts.push({ field: 'protocol', screen: protocolResult.observed, panel: protocolResult.expected });
+ }
+ const decision = conflicts.length ? 'lockdown' : 'release';
+ const explanation = conflicts.length
+ ? conflicts.map(item => `${FIELD_LABELS[item.field] || '协议'}不一致`).join(';')
+ : '画面与主控数据一致。';
+ return {
+ decision,
+ conflicts,
+ explanation,
+ presentationTone: 'neutral',
+ highlightConflictBeforeDecision: false,
+ };
+}
+
+function evaluateInvestigationEvidence(discoveredEvidence = []) {
+ const contradictions = discoveredEvidence.filter(item => item?.contradicts && item?.conflictKey && item?.source);
+ const groups = new Map();
+ for (const evidence of contradictions) {
+ const sources = groups.get(evidence.conflictKey) || new Set();
+ sources.add(evidence.source);
+ groups.set(evidence.conflictKey, sources);
+ }
+ const corroborated = [...groups.entries()].filter(([, sources]) => sources.size >= 2);
+ const verificationPaths = [...new Set(
+ corroborated.flatMap(([, sources]) => [...sources]),
+ )].sort();
+ const ready = corroborated.length > 0;
+ return {
+ ready,
+ decision: ready ? 'lockdown' : null,
+ conflicts: corroborated.map(([conflictKey]) => conflictKey),
+ verificationPaths,
+ presentationTone: 'neutral',
+ };
+}
+
+function isEvidenceJudgeableWithoutAudio(shift = {}) {
+ const conflicts = compareCoreEvidence(shift.screenData, shift.panelData);
+ const cameras = shift.evidence?.cameras || [];
+ const tools = shift.evidence?.tools || [];
+ return conflicts.length > 0 || cameras.length > 0 || tools.some(tool => tool !== 'audio');
+}
+
+__exports_src_evidenceEngine_js["compareCoreEvidence"] = compareCoreEvidence;
+__exports_src_evidenceEngine_js["evaluateEvidence"] = evaluateEvidence;
+__exports_src_evidenceEngine_js["evaluateInvestigationEvidence"] = evaluateInvestigationEvidence;
+__exports_src_evidenceEngine_js["isEvidenceJudgeableWithoutAudio"] = isEvidenceJudgeableWithoutAudio;
+}
+var compareCoreEvidence = __exports_src_evidenceEngine_js["compareCoreEvidence"];
+var evaluateEvidence = __exports_src_evidenceEngine_js["evaluateEvidence"];
+var evaluateInvestigationEvidence = __exports_src_evidenceEngine_js["evaluateInvestigationEvidence"];
+var isEvidenceJudgeableWithoutAudio = __exports_src_evidenceEngine_js["isEvidenceJudgeableWithoutAudio"];
+
+// --- src/investigationTools.js ---
+var __exports_src_investigationTools_js = {};
+{
+const TOOL_CONFIG = Object.freeze({
+ thermal: Object.freeze({ uses: 2, powerCost: 8, evidenceKey: 'thermal' }),
+ replay: Object.freeze({ uses: 2, powerCost: 4, evidenceKey: 'replay' }),
+ protocol: Object.freeze({ uses: Number.POSITIVE_INFINITY, powerCost: 0, evidenceKey: 'protocol' }),
+});
+
+function cloneInvestigationState(state) {
+ return {
+ ...state,
+ tools: Object.fromEntries(
+ Object.entries(state.tools || {}).map(([id, tool]) => [id, { ...tool }]),
+ ),
+ discoveredEvidence: [...(state.discoveredEvidence || [])],
+ };
+}
+
+function createInvestigationState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ activeCamera: 'cam01',
+ tools: Object.fromEntries(
+ Object.entries(TOOL_CONFIG).map(([id, config]) => [id, {
+ remaining: config.uses,
+ powerCost: config.powerCost,
+ }]),
+ ),
+ discoveredEvidence: [],
+ };
+}
+
+function switchCamera(state, cameraId, shift = {}) {
+ if (!(shift.cameras || []).includes(cameraId)) {
+ return { state, accepted: false, reason: 'camera-unavailable', visibleEvidence: [] };
+ }
+ const next = cloneInvestigationState(state);
+ next.activeCamera = cameraId;
+ const visibleEvidence = [...(shift.evidence?.cameras?.[cameraId] || [])];
+ for (const evidence of visibleEvidence) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, visibleEvidence };
+}
+
+function useInvestigationTool(state, toolId, shift = {}) {
+ const config = TOOL_CONFIG[toolId];
+ const currentTool = state?.tools?.[toolId];
+ if (!config || !currentTool) return { state, accepted: false, reason: 'unknown-tool' };
+ if (currentTool.remaining <= 0) return { state, accepted: false, reason: 'no-uses' };
+ if ((state.power ?? 0) < config.powerCost) return { state, accepted: false, reason: 'insufficient-power' };
+
+ const next = cloneInvestigationState(state);
+ next.power = Math.max(0, next.power - config.powerCost);
+ if (Number.isFinite(next.tools[toolId].remaining)) next.tools[toolId].remaining -= 1;
+ const discoveredEvidence = toolId === 'protocol'
+ ? [...(shift.activeProtocols || [])]
+ : shift.evidence?.[config.evidenceKey] ?? null;
+ const evidenceItems = Array.isArray(discoveredEvidence)
+ ? discoveredEvidence
+ : discoveredEvidence ? [discoveredEvidence] : [];
+ for (const evidence of evidenceItems) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, discoveredEvidence };
+}
+
+__exports_src_investigationTools_js["createInvestigationState"] = createInvestigationState;
+__exports_src_investigationTools_js["switchCamera"] = switchCamera;
+__exports_src_investigationTools_js["useInvestigationTool"] = useInvestigationTool;
+}
+var createInvestigationState = __exports_src_investigationTools_js["createInvestigationState"];
+var switchCamera = __exports_src_investigationTools_js["switchCamera"];
+var useInvestigationTool = __exports_src_investigationTools_js["useInvestigationTool"];
+
+// --- src/identitySystem.js ---
+var __exports_src_identitySystem_js = {};
+{
+function verifyPassengerIdentity(passenger = {}, observation = {}) {
+ const conflicts = [];
+ if (passenger.badge != null && observation.badge !== passenger.badge) conflicts.push('badge');
+ if (Array.isArray(passenger.allowedFloors)
+ && !passenger.allowedFloors.map(String).includes(String(observation.requestedFloor))) {
+ conflicts.push('floor');
+ }
+ return {
+ valid: conflicts.length === 0,
+ conflicts,
+ passengerId: passenger.id ?? null,
+ verificationPaths: ['cam01', 'protocol'],
+ };
+}
+
+function countPassengersForPanel(passengers = []) {
+ return passengers.filter(passenger => passenger.countMode !== 'ignore').length;
+}
+
+__exports_src_identitySystem_js["verifyPassengerIdentity"] = verifyPassengerIdentity;
+__exports_src_identitySystem_js["countPassengersForPanel"] = countPassengersForPanel;
+}
+var verifyPassengerIdentity = __exports_src_identitySystem_js["verifyPassengerIdentity"];
+var countPassengersForPanel = __exports_src_identitySystem_js["countPassengersForPanel"];
+
+// --- src/eventChainEngine.js ---
+var __exports_src_eventChainEngine_js = {};
+{
+function cloneChainState(state) {
+ return {
+ chains: Object.fromEntries(Object.entries(state.chains || {}).map(([id, value]) => [id, { ...value }])),
+ flags: [...(state.flags || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+function createEventChainState(chains = []) {
+ return {
+ chains: Object.fromEntries(chains.map(chain => [chain.id, { stepIndex: 0, completed: false }])),
+ flags: [...new Set(chains.flatMap(chain => chain.initialFlags || []))],
+ history: [],
+ };
+}
+
+function getCurrentEventStep(state, chain) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return null;
+ return chain.steps?.[progress.stepIndex] ?? null;
+}
+
+function advanceEventChain(state, chain, outcome = {}) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return { state, accepted: false, completed: Boolean(progress?.completed), consequences: [] };
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return { state, accepted: false, completed: true, consequences: [] };
+
+ const next = cloneChainState(state);
+ if (outcome.correct === false) {
+ next.flags.push(...(step.onWrongFlags || []));
+ next.flags = [...new Set(next.flags)];
+ }
+ const nextIndex = progress.stepIndex + 1;
+ const completed = nextIndex >= chain.steps.length;
+ next.chains[chain.id] = { stepIndex: nextIndex, completed };
+ next.history.push({ chainId: chain.id, stepId: step.id, correct: outcome.correct !== false });
+ const consequences = completed
+ ? (chain.consequences || []).filter(item => !item.flag || next.flags.includes(item.flag))
+ : [];
+ return { state: next, accepted: true, completed, consequences };
+}
+
+__exports_src_eventChainEngine_js["createEventChainState"] = createEventChainState;
+__exports_src_eventChainEngine_js["getCurrentEventStep"] = getCurrentEventStep;
+__exports_src_eventChainEngine_js["advanceEventChain"] = advanceEventChain;
+}
+var createEventChainState = __exports_src_eventChainEngine_js["createEventChainState"];
+var getCurrentEventStep = __exports_src_eventChainEngine_js["getCurrentEventStep"];
+var advanceEventChain = __exports_src_eventChainEngine_js["advanceEventChain"];
+
+// --- src/highRiskResolution.js ---
+var __exports_src_highRiskResolution_js = {};
+{
+const HIGH_RISK_ACTIONS = Object.freeze(['emergencyStop', 'restart', 'lockdownFloor']);
+
+function cloneHighRiskState(state) {
+ return {
+ ...state,
+ resolvedEvents: [...(state.resolvedEvents || [])],
+ nextShiftModifiers: [...(state.nextShiftModifiers || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+function createHighRiskState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ resolvedEvents: [],
+ nextShiftModifiers: [],
+ history: [],
+ gameOver: false,
+ };
+}
+
+function resolveHighRiskAction(state, event = {}, action) {
+ if (!HIGH_RISK_ACTIONS.includes(action)) return { state, accepted: false, correct: false, reason: 'unknown-action' };
+ const cost = Math.max(0, Number(event.costs?.[action] || 0));
+ if ((state.power ?? 0) < cost) return { state, accepted: false, correct: false, reason: 'insufficient-power' };
+
+ const next = cloneHighRiskState(state);
+ next.power -= cost;
+ const correct = (event.acceptedActions || []).includes(action);
+ if (correct && event.id && !next.resolvedEvents.includes(event.id)) next.resolvedEvents.push(event.id);
+ const modifier = correct ? event.successModifier : event.wrongModifiers?.[action];
+ if (modifier && !next.nextShiftModifiers.includes(modifier)) next.nextShiftModifiers.push(modifier);
+ next.history.push({ eventId: event.id ?? null, action, correct, powerCost: cost });
+ return { state: next, accepted: true, correct };
+}
+
+__exports_src_highRiskResolution_js["createHighRiskState"] = createHighRiskState;
+__exports_src_highRiskResolution_js["resolveHighRiskAction"] = resolveHighRiskAction;
+}
+var createHighRiskState = __exports_src_highRiskResolution_js["createHighRiskState"];
+var resolveHighRiskAction = __exports_src_highRiskResolution_js["resolveHighRiskAction"];
+
+// --- src/contamination.js ---
+var __exports_src_contamination_js = {};
+{
+function clamp(value, min = 0, max = 100) {
+ return Math.max(min, Math.min(max, Number(value) || 0));
+}
+
+function getContaminationTier(value) {
+ const normalized = clamp(value);
+ if (normalized >= 76) return 'severe';
+ if (normalized >= 51) return 'medium';
+ if (normalized >= 26) return 'light';
+ return 'normal';
+}
+
+function createContaminationState(value = 0) {
+ const normalized = clamp(value);
+ return { value: normalized, tier: getContaminationTier(normalized), history: [] };
+}
+
+function changeContamination(state, delta, reason) {
+ const current = state || createContaminationState();
+ const value = clamp(current.value + Number(delta || 0));
+ return {
+ value,
+ tier: getContaminationTier(value),
+ history: [...(current.history || []), { delta: Number(delta || 0), reason, value }],
+ };
+}
+
+function applyDecisionContamination(state, decision = {}) {
+ const effects = decision.contaminationEffects || {};
+ const delta = decision.correct === false
+ ? Number(effects.onMiss || 0)
+ : Number(effects.onCorrect || 0);
+ return changeContamination(state, delta, {
+ type: decision.correct === false ? 'wrong-decision' : 'correct-decision',
+ contentId: decision.contentId ?? null,
+ });
+}
+
+function deriveContaminationEffects(value) {
+ const tier = getContaminationTier(value);
+ const reliability = {
+ normal: {
+ reliable: ['panel', 'cam01', 'cam03', 'cam07', 'thermal', 'replay'],
+ unreliable: [],
+ },
+ light: {
+ reliable: ['panel', 'cam01', 'cam03', 'thermal', 'replay'],
+ unreliable: ['cam07'],
+ },
+ medium: {
+ reliable: ['cam01', 'thermal', 'replay'],
+ unreliable: ['panel', 'cam07'],
+ },
+ severe: {
+ reliable: ['thermal', 'replay'],
+ unreliable: ['panel', 'cam01', 'cam03', 'cam07'],
+ },
+ }[tier];
+ const effects = {
+ tier,
+ chromaticAberration: tier === 'normal' ? 0 : tier === 'light' ? 0.08 : tier === 'medium' ? 0.16 : 0.24,
+ timecodeJitter: tier === 'medium' || tier === 'severe',
+ edgeGhosting: tier !== 'normal',
+ protocolGlyphDropout: tier === 'severe',
+ audioDropout: tier === 'medium' || tier === 'severe',
+ reliableVerificationPaths: reliability.reliable,
+ unreliableVerificationPaths: reliability.unreliable,
+ };
+ return effects;
+}
+
+__exports_src_contamination_js["getContaminationTier"] = getContaminationTier;
+__exports_src_contamination_js["createContaminationState"] = createContaminationState;
+__exports_src_contamination_js["changeContamination"] = changeContamination;
+__exports_src_contamination_js["applyDecisionContamination"] = applyDecisionContamination;
+__exports_src_contamination_js["deriveContaminationEffects"] = deriveContaminationEffects;
+}
+var getContaminationTier = __exports_src_contamination_js["getContaminationTier"];
+var createContaminationState = __exports_src_contamination_js["createContaminationState"];
+var changeContamination = __exports_src_contamination_js["changeContamination"];
+var applyDecisionContamination = __exports_src_contamination_js["applyDecisionContamination"];
+var deriveContaminationEffects = __exports_src_contamination_js["deriveContaminationEffects"];
+
+// --- src/debriefTimeline.js ---
+var __exports_src_debriefTimeline_js = {};
+{
+function timelineItem(type, entry) {
+ return { type, ...entry, sequence: Number(entry.sequence || 0) };
+}
+
+function buildDebriefTimeline({ decisions = [], eventHistory = [], contaminationHistory = [] } = {}) {
+ const timeline = [
+ ...decisions.map(entry => timelineItem('decision', entry)),
+ ...eventHistory.map(entry => timelineItem('event-chain', entry)),
+ ...contaminationHistory.map(entry => timelineItem('contamination', entry)),
+ ].sort((a, b) => a.sequence - b.sequence);
+ const correct = decisions.filter(item => item.correct).length;
+ const wrong = decisions.length - correct;
+ const peakContamination = contaminationHistory.reduce(
+ (peak, item) => Math.max(peak, Number(item.value || 0)),
+ 0,
+ );
+ return {
+ timeline,
+ summary: {
+ decisions: decisions.length,
+ correct,
+ wrong,
+ accuracy: decisions.length ? correct / decisions.length : 0,
+ peakContamination,
+ eventStages: eventHistory.length,
+ },
+ };
+}
+
+function matchesEnding(ending, result) {
+ const condition = ending.condition || ending.conditions || {};
+ if (condition.requiredFlag && !(result.flags || []).includes(condition.requiredFlag)) return false;
+ if (condition.minContamination != null && result.contamination < condition.minContamination) return false;
+ if (condition.maxContamination != null && result.contamination > condition.maxContamination) return false;
+ if (condition.minAccuracy != null && result.accuracy < condition.minAccuracy) return false;
+ if (condition.maxAccuracy != null && result.accuracy > condition.maxAccuracy) return false;
+ return true;
+}
+
+function selectNightEnding(endings = [], result = {}) {
+ return [...endings]
+ .filter(ending => matchesEnding(ending, result))
+ .sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0) || a.id.localeCompare(b.id))[0] ?? null;
+}
+
+__exports_src_debriefTimeline_js["buildDebriefTimeline"] = buildDebriefTimeline;
+__exports_src_debriefTimeline_js["selectNightEnding"] = selectNightEnding;
+}
+var buildDebriefTimeline = __exports_src_debriefTimeline_js["buildDebriefTimeline"];
+var selectNightEnding = __exports_src_debriefTimeline_js["selectNightEnding"];
+
+// --- src/nightInteraction.js ---
+var __exports_src_nightInteraction_js = {};
+{
+
+
+
+const CATEGORIES = Object.freeze(['person', 'quantity', 'space', 'time', 'device', 'dynamic']);
+const HIGH_RISK_COSTS = Object.freeze({ emergencyStop: 15, restart: 10, lockdownFloor: 12 });
+
+function clone(value) {
+ if (Array.isArray(value)) return value.map(clone);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
+ }
+ return value;
+}
+
+function appendDecision(state, decision) {
+ const next = clone(state);
+ const decisions = next.night.decisions || [];
+ const sequence = Number(next.night.timelineSequence || 0) + 1;
+ next.night.timelineSequence = sequence;
+ decisions.push({ sequence, ...decision });
+ next.night.decisions = decisions;
+ return next;
+}
+
+function openProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = 'protocolQuery';
+ next.night.protocolQuery = clone(next.night.activeProtocols || []);
+ return next;
+}
+
+function closeProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = null;
+ return next;
+}
+
+function verifyCurrentIdentity(state) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity') {
+ return { state, accepted: false, reason: 'not-identity-round' };
+ }
+ const evidence = shift.evidence?.cameras?.cam01?.[0];
+ if (!evidence) return { state, accepted: false, reason: 'identity-evidence-missing' };
+ const next = clone(state);
+ const discovered = next.investigation.discoveredEvidence || [];
+ if (!discovered.some(item => item.id === evidence.id)) discovered.push(clone(evidence));
+ next.investigation.discoveredEvidence = discovered;
+ next.lastFeedback = `核验结果:${evidence.observation}`;
+ return { state: next, accepted: true, evidence: clone(evidence) };
+}
+
+function resolveIdentityDecision(state, choice) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity' || !['release', 'reject'].includes(choice)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-identity-decision' };
+ }
+ const expected = shift.decision === 'anomaly' ? 'reject' : 'release';
+ const correct = choice === expected;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: `identity:${choice}`,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.roundType = 'quick';
+ next.lastFeedback = correct
+ ? (choice === 'release' ? '身份一致,准予放行' : '身份冲突,拒绝通行')
+ : '身份判断错误,污染已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct };
+}
+
+function classifyCurrentShift(state, category) {
+ const shift = state?.night?.currentShift;
+ if (!shift || !CATEGORIES.includes(category)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-classification' };
+ }
+ const correct = shift.category === category;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'classification',
+ classification: category,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.overlay = null;
+ next.night.roundType = shift.roundType === 'highRisk' || shift.highRisk ? 'highRisk' : 'quick';
+ next.lastFeedback = correct ? `分类确认:${category}` : `分类不符:${category}`;
+ return { state: next, accepted: true, correct };
+}
+
+function acceptedHighRiskAction(shift) {
+ if (shift.resolutionAction === 'emergencyStop') return 'emergencyStop';
+ if (shift.resolutionAction === 'restart') return 'restart';
+ return 'lockdownFloor';
+}
+
+function resolveCurrentHighRisk(state, action) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'highRisk') {
+ return { state, accepted: false, correct: false, reason: 'not-high-risk' };
+ }
+ const highRisk = createHighRiskState({ power: state.power });
+ highRisk.nextShiftModifiers = clone(state.night.nextShiftModifiers || []);
+ const result = resolveHighRiskAction(highRisk, {
+ id: shift.id,
+ acceptedActions: [acceptedHighRiskAction(shift)],
+ costs: HIGH_RISK_COSTS,
+ successModifier: `resolved:${shift.id}`,
+ wrongModifiers: {
+ emergencyStop: 'power-grid-stress',
+ restart: 'control-reliability-down',
+ lockdownFloor: 'camera-delay',
+ },
+ }, action);
+ if (!result.accepted) return { ...result, state };
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'highRisk',
+ action,
+ correct: result.correct,
+ });
+ next.power = result.state.power;
+ next.investigation.power = result.state.power;
+ next.night.nextShiftModifiers = result.state.nextShiftModifiers;
+ next.night.roundType = 'quick';
+ next.lastFeedback = result.correct ? '高危处置完成' : '处置失误已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct: result.correct };
+}
+
+function createNightDebrief(state, endings = []) {
+ const report = buildDebriefTimeline({
+ decisions: state?.night?.decisions || [],
+ eventHistory: state?.night?.eventChainHistory || Object.values(state?.night?.eventChains || {}).flatMap(chain => chain.history || []),
+ contaminationHistory: state?.contamination?.history || [],
+ });
+ const eventChainFlags = state?.night?.eventChainFlags || [];
+ const nextShiftModifiers = state?.night?.nextShiftModifiers || [];
+ return {
+ ...report,
+ nextShiftModifiers: [...nextShiftModifiers],
+ ending: selectNightEnding(endings, {
+ flags: eventChainFlags,
+ contamination: Number(state?.contamination?.value || 0),
+ accuracy: report.summary.accuracy,
+ }),
+ };
+}
+
+__exports_src_nightInteraction_js["openProtocolQuery"] = openProtocolQuery;
+__exports_src_nightInteraction_js["closeProtocolQuery"] = closeProtocolQuery;
+__exports_src_nightInteraction_js["verifyCurrentIdentity"] = verifyCurrentIdentity;
+__exports_src_nightInteraction_js["resolveIdentityDecision"] = resolveIdentityDecision;
+__exports_src_nightInteraction_js["classifyCurrentShift"] = classifyCurrentShift;
+__exports_src_nightInteraction_js["resolveCurrentHighRisk"] = resolveCurrentHighRisk;
+__exports_src_nightInteraction_js["createNightDebrief"] = createNightDebrief;
+}
+var openProtocolQuery = __exports_src_nightInteraction_js["openProtocolQuery"];
+var closeProtocolQuery = __exports_src_nightInteraction_js["closeProtocolQuery"];
+var verifyCurrentIdentity = __exports_src_nightInteraction_js["verifyCurrentIdentity"];
+var resolveIdentityDecision = __exports_src_nightInteraction_js["resolveIdentityDecision"];
+var classifyCurrentShift = __exports_src_nightInteraction_js["classifyCurrentShift"];
+var resolveCurrentHighRisk = __exports_src_nightInteraction_js["resolveCurrentHighRisk"];
+var createNightDebrief = __exports_src_nightInteraction_js["createNightDebrief"];
// --- src/anomalyContent.js ---
+var __exports_src_anomalyContent_js = {};
+{
/**
* anomalyContent.js — 异常内容模式定义与结构化数据
*
@@ -772,8 +1476,33 @@ function getAnomalyCctvStates() {
return [...states];
}
+__exports_src_anomalyContent_js["ANOMALY_CONTENTS"] = ANOMALY_CONTENTS;
+__exports_src_anomalyContent_js["findAnomalyContent"] = findAnomalyContent;
+__exports_src_anomalyContent_js["getAllAnomalyContents"] = getAllAnomalyContents;
+__exports_src_anomalyContent_js["isDataConsistent"] = isDataConsistent;
+__exports_src_anomalyContent_js["getConflictFields"] = getConflictFields;
+__exports_src_anomalyContent_js["NORMAL_VARIANTS"] = NORMAL_VARIANTS;
+__exports_src_anomalyContent_js["pickNormalVariant"] = pickNormalVariant;
+__exports_src_anomalyContent_js["getAnomalyCctvState"] = getAnomalyCctvState;
+__exports_src_anomalyContent_js["getAnomaliesByCctvState"] = getAnomaliesByCctvState;
+__exports_src_anomalyContent_js["getNormalCctvStates"] = getNormalCctvStates;
+__exports_src_anomalyContent_js["getAnomalyCctvStates"] = getAnomalyCctvStates;
+}
+var ANOMALY_CONTENTS = __exports_src_anomalyContent_js["ANOMALY_CONTENTS"];
+var findAnomalyContent = __exports_src_anomalyContent_js["findAnomalyContent"];
+var getAllAnomalyContents = __exports_src_anomalyContent_js["getAllAnomalyContents"];
+var isDataConsistent = __exports_src_anomalyContent_js["isDataConsistent"];
+var getConflictFields = __exports_src_anomalyContent_js["getConflictFields"];
+var NORMAL_VARIANTS = __exports_src_anomalyContent_js["NORMAL_VARIANTS"];
+var pickNormalVariant = __exports_src_anomalyContent_js["pickNormalVariant"];
+var getAnomalyCctvState = __exports_src_anomalyContent_js["getAnomalyCctvState"];
+var getAnomaliesByCctvState = __exports_src_anomalyContent_js["getAnomaliesByCctvState"];
+var getNormalCctvStates = __exports_src_anomalyContent_js["getNormalCctvStates"];
+var getAnomalyCctvStates = __exports_src_anomalyContent_js["getAnomalyCctvStates"];
// --- src/visualState.js ---
+var __exports_src_visualState_js = {};
+{
/**
* visualState.js — 驱动 CCTV 视觉状态的核心映射
*
@@ -905,11 +1634,35 @@ function deriveVisualState(state) {
};
}
+__exports_src_visualState_js["getAnomalyResolutionAction"] = getAnomalyResolutionAction;
+__exports_src_visualState_js["deriveVisualState"] = deriveVisualState;
+}
+var getAnomalyResolutionAction = __exports_src_visualState_js["getAnomalyResolutionAction"];
+var deriveVisualState = __exports_src_visualState_js["deriveVisualState"];
// --- src/state.js ---
+var __exports_src_state_js = {};
+{
+
+
+
+function createNightState() {
+ return {
+ activeProtocols: [],
+ currentShift: null,
+ roundType: 'quick',
+ shiftIndex: 0,
+ decisions: [],
+ eventChains: {},
+ eventChainFlags: [],
+ eventChainHistory: [],
+ timelineSequence: 0,
+ nextShiftModifiers: [],
+ };
+}
function createInitialState() {
const c = CONFIG.initial;
@@ -922,6 +1675,9 @@ function createInitialState() {
power: c.power,
stability: c.stability,
anomalyLevel: c.anomalyLevel,
+ contamination: createContaminationState(),
+ night: createNightState(),
+ investigation: createInvestigationState({ power: c.power }),
passengers: c.passengers,
gameOver: c.gameOver,
result: 'playing',
@@ -1106,8 +1862,31 @@ function recordFailure(state) {
return next;
}
+__exports_src_state_js["createInitialState"] = createInitialState;
+__exports_src_state_js["cloneState"] = cloneState;
+__exports_src_state_js["appendLog"] = appendLog;
+__exports_src_state_js["clamp"] = clamp;
+__exports_src_state_js["checkFailure"] = checkFailure;
+__exports_src_state_js["saveSnapshot"] = saveSnapshot;
+__exports_src_state_js["reviveFromAd"] = reviveFromAd;
+__exports_src_state_js["tickState"] = tickState;
+__exports_src_state_js["recordSuccessfulShift"] = recordSuccessfulShift;
+__exports_src_state_js["recordFailure"] = recordFailure;
+}
+var createInitialState = __exports_src_state_js["createInitialState"];
+var cloneState = __exports_src_state_js["cloneState"];
+var appendLog = __exports_src_state_js["appendLog"];
+var clamp = __exports_src_state_js["clamp"];
+var checkFailure = __exports_src_state_js["checkFailure"];
+var saveSnapshot = __exports_src_state_js["saveSnapshot"];
+var reviveFromAd = __exports_src_state_js["reviveFromAd"];
+var tickState = __exports_src_state_js["tickState"];
+var recordSuccessfulShift = __exports_src_state_js["recordSuccessfulShift"];
+var recordFailure = __exports_src_state_js["recordFailure"];
// --- src/incidentDecision.js ---
+var __exports_src_incidentDecision_js = {};
+{
function openInspection(state, options) {
@@ -1220,11 +1999,22 @@ function expireInspection(state) {
return { state: checkFailure(next), timedOut: true };
}
+__exports_src_incidentDecision_js["openInspection"] = openInspection;
+__exports_src_incidentDecision_js["submitInspection"] = submitInspection;
+__exports_src_incidentDecision_js["expireInspection"] = expireInspection;
+}
+var openInspection = __exports_src_incidentDecision_js["openInspection"];
+var submitInspection = __exports_src_incidentDecision_js["submitInspection"];
+var expireInspection = __exports_src_incidentDecision_js["expireInspection"];
// --- src/events.js ---
+var __exports_src_events_js = {};
+{
+const skinHiddenLogLookup = getHiddenLog;
+
/**
* 从皮肤数据动态构建异常事件数组
*/
@@ -1297,7 +2087,7 @@ function applyAnomaly(state, id) {
next.anomaliesTriggeredTotal = (next.anomaliesTriggeredTotal ?? 0) + 1;
next.maxAnomalySeverity = Math.max(next.maxAnomalySeverity ?? 0, event.severity);
// 添加关联隐藏日志(不重复)
- const raw = getHiddenLog(id);
+ const raw = skinHiddenLogLookup(id);
if (raw && !next.hiddenLogs.some(h => h.id === id + '_log')) {
next.hiddenLogs.push({ id: id + '_log', title: raw.title, content: raw.content, locked: true });
next = appendLog(next, 'info', t('ui.hiddenLogCaptured', { title: raw.title }));
@@ -1323,7 +2113,7 @@ const _buildHiddenLogsMap = () => {
const map = {};
const anomalies = getAnomalies();
for (const a of anomalies) {
- const hl = getHiddenLog(a.id);
+ const hl = skinHiddenLogLookup(a.id);
if (hl) {
map[a.id] = { id: `${a.id}_log`, title: hl.title, content: hl.content };
}
@@ -1333,8 +2123,21 @@ const _buildHiddenLogsMap = () => {
const HIDDEN_LOGS = _buildHiddenLogsMap();
+__exports_src_events_js["ANOMALIES"] = ANOMALIES;
+__exports_src_events_js["findAnomaly"] = findAnomaly;
+__exports_src_events_js["applyAnomaly"] = applyAnomaly;
+__exports_src_events_js["pickNextAnomaly"] = pickNextAnomaly;
+__exports_src_events_js["HIDDEN_LOGS"] = HIDDEN_LOGS;
+}
+var ANOMALIES = __exports_src_events_js["ANOMALIES"];
+var findAnomaly = __exports_src_events_js["findAnomaly"];
+var applyAnomaly = __exports_src_events_js["applyAnomaly"];
+var pickNextAnomaly = __exports_src_events_js["pickNextAnomaly"];
+var HIDDEN_LOGS = __exports_src_events_js["HIDDEN_LOGS"];
// --- src/actions.js ---
+var __exports_src_actions_js = {};
+{
@@ -1535,19 +2338,216 @@ function getAvailableActions() {
return ACTION_IDS.map(id => ({ id, label: actionLabel(id) }));
}
+__exports_src_actions_js["performAction"] = performAction;
+__exports_src_actions_js["getAvailableActions"] = getAvailableActions;
+}
+var performAction = __exports_src_actions_js["performAction"];
+var getAvailableActions = __exports_src_actions_js["getAvailableActions"];
+
+// --- src/nightScheduler.js ---
+var __exports_src_nightScheduler_js = {};
+{
+
+
+
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function requireContentList(content, key) {
+ const list = content?.[key];
+ if (!Array.isArray(list) || list.length === 0) {
+ throw new Error(`V5 night scheduler requires non-empty ${key}`);
+ }
+ return list;
+}
+
+function pick(list, random) {
+ const value = Number(random());
+ const normalized = Number.isFinite(value) ? Math.max(0, Math.min(0.999999999999, value)) : 0;
+ return list[Math.floor(normalized * list.length)];
+}
+
+const NEXT_SHIFT_MODIFIER_VISUALS = Object.freeze({
+ duplicate_feed: '14_duplicate_subject',
+ floor_13_bleed: '16_wrong_floor',
+ unreliable_cam07: '10_signal_lost',
+});
+
+function installShift(state, shift, shiftKind, shiftIndex, activeProtocols, eventMeta = null) {
+ const next = clone(state);
+ const protocols = clone(activeProtocols);
+ const pendingModifiers = [...(next.night.nextShiftModifiers || [])];
+ const modifierVisualState = pendingModifiers
+ .map(modifier => NEXT_SHIFT_MODIFIER_VISUALS[modifier])
+ .find(Boolean);
+ next.night.activeProtocols = protocols;
+ next.night.currentShift = {
+ ...clone(shift),
+ ...(modifierVisualState ? { visualState: modifierVisualState } : {}),
+ ...(pendingModifiers.length ? { appliedModifiers: pendingModifiers } : {}),
+ shiftKind,
+ activeProtocols: clone(protocols),
+ ...(eventMeta ? {
+ eventChainId: eventMeta.chainId,
+ eventChainStep: eventMeta.stepId,
+ } : {}),
+ };
+ next.night.nextShiftModifiers = [];
+ next.night.roundType = shift.roundType || 'quick';
+ next.night.shiftIndex = shiftIndex;
+ next.investigation = createInvestigationState({ power: next.power });
+ return next;
+}
+
+function initialiseEventChains(state, content, random) {
+ if (!Array.isArray(content?.eventChains) || content.eventChains.length === 0) return state;
+ const next = clone(state);
+ const chainState = createEventChainState(content.eventChains);
+ next.night.eventChains = chainState.chains;
+ next.night.eventChainFlags = chainState.flags;
+ next.night.eventChainHistory = chainState.history;
+ next.night.activeEventChainId = pick(content.eventChains, random).id;
+ return next;
+}
+
+function getActiveChainStep(state, content) {
+ if (Number(state?.tutorialStep || 0) < 4) return null;
+ const chainId = state?.night?.activeEventChainId;
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ const progress = state?.night?.eventChains?.[chainId];
+ if (!chain || !progress || progress.completed) return null;
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return null;
+ const shift = [...(content.normalShifts || []), ...(content.anomalies || [])]
+ .find(item => item.id === step.contentId);
+ return shift ? { chain, progress, step, shift } : null;
+}
+
+function createNightSchedule(state, content, options = {}) {
+ const normalShifts = requireContentList(content, 'normalShifts');
+ const anomalies = requireContentList(content, 'anomalies');
+ const protocols = requireContentList(content, 'protocols');
+ const random = options.random || Math.random;
+ const firstShift = pick(normalShifts, random);
+ const activeProtocols = generateNightProtocols({
+ protocols,
+ shifts: [...normalShifts, ...anomalies],
+ count: options.protocolCount ?? 3,
+ random,
+ });
+ const scheduled = installShift(state, firstShift, 'normal', 0, activeProtocols);
+ return initialiseEventChains(scheduled, content, random);
+}
+
+function scheduleNextNightShift(state, content, options = {}) {
+ requireContentList(content, 'normalShifts');
+ requireContentList(content, 'anomalies');
+ const random = options.random || Math.random;
+ const nextIndex = Number(state?.night?.shiftIndex || 0) + 1;
+ const activeProtocols = state?.night?.activeProtocols?.length
+ ? state.night.activeProtocols
+ : requireContentList(content, 'protocols');
+ const chainStep = getActiveChainStep(state, content);
+ if (chainStep) {
+ return installShift(
+ state,
+ chainStep.shift,
+ chainStep.shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ nextIndex,
+ activeProtocols,
+ { chainId: chainStep.chain.id, stepId: chainStep.step.id },
+ );
+ }
+ const shiftKind = nextIndex % 2 === 0 ? 'normal' : 'anomaly';
+ const shift = pick(content[shiftKind === 'normal' ? 'normalShifts' : 'anomalies'], random);
+ return installShift(state, shift, shiftKind, nextIndex, activeProtocols);
+}
+
+function advanceCurrentNightEventChain(state, content, outcome) {
+ if (Number(state?.tutorialStep || 0) < 4) return { state, advanced: false };
+ const chainId = state?.night?.activeEventChainId;
+ if (!chainId || !state?.night?.eventChains?.[chainId]) return { state, advanced: false };
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ if (!chain) return { state, advanced: false };
+ const chainState = {
+ chains: state.night.eventChains,
+ flags: state.night.eventChainFlags || [],
+ history: state.night.eventChainHistory || [],
+ };
+ const result = advanceEventChain(chainState, chain, outcome);
+ const next = clone(state);
+ let timelineSequence = Number(next.night.timelineSequence || 0);
+ const eventHistory = result.state.history.map(item => {
+ if (Number.isFinite(Number(item.sequence))) return item;
+ timelineSequence += 1;
+ return { ...item, sequence: timelineSequence };
+ });
+ next.night.timelineSequence = timelineSequence;
+ next.night.eventChains = {
+ ...next.night.eventChains,
+ [chainId]: {
+ ...result.state.chains[chainId],
+ history: eventHistory.filter(item => item.chainId === chainId),
+ },
+ };
+ next.night.eventChainFlags = result.state.flags;
+ next.night.eventChainHistory = eventHistory;
+ if (result.completed) next.night.activeEventChainId = null;
+ for (const consequence of result.consequences || []) {
+ if (Number(consequence.contaminationDelta || 0) !== 0) {
+ next.contamination = changeContamination(
+ next.contamination,
+ Number(consequence.contaminationDelta),
+ `event-chain:${chainId}`,
+ );
+ const history = next.contamination.history || [];
+ if (history.length > 0 && !Number.isFinite(Number(history.at(-1).sequence))) {
+ next.night.timelineSequence += 1;
+ history[history.length - 1] = {
+ ...history.at(-1),
+ sequence: next.night.timelineSequence,
+ };
+ next.contamination.history = history;
+ }
+ }
+ if (consequence.nextShiftModifier) {
+ next.night.nextShiftModifiers = [
+ ...(next.night.nextShiftModifiers || []),
+ consequence.nextShiftModifier,
+ ];
+ }
+ }
+ return { state: next, advanced: true, completed: Boolean(result.completed), result };
+}
+
+__exports_src_nightScheduler_js["createNightSchedule"] = createNightSchedule;
+__exports_src_nightScheduler_js["scheduleNextNightShift"] = scheduleNextNightShift;
+__exports_src_nightScheduler_js["advanceCurrentNightEventChain"] = advanceCurrentNightEventChain;
+}
+var createNightSchedule = __exports_src_nightScheduler_js["createNightSchedule"];
+var scheduleNextNightShift = __exports_src_nightScheduler_js["scheduleNextNightShift"];
+var advanceCurrentNightEventChain = __exports_src_nightScheduler_js["advanceCurrentNightEventChain"];
// --- src/runtimeSession.js ---
+var __exports_src_runtimeSession_js = {};
+{
+
-function createRuntimeSession() {
+function createRuntimeSession(options = {}) {
+ const initialState = createInitialState();
return {
- state: createInitialState(),
+ state: options.content
+ ? createNightSchedule(initialState, options.content, options)
+ : initialState,
nextAnomalyAt: CONFIG.anomaly.firstTriggerAt,
};
}
-function restartRuntimeSession(previousSession = null) {
- const session = createRuntimeSession();
+function restartRuntimeSession(previousSession = null, options = {}) {
+ const session = createRuntimeSession(options);
const previous = previousSession?.state;
if (!previous) return session;
@@ -1570,8 +2570,19 @@ function scheduleNextAnomalyAfterRevive(elapsed) {
return elapsed + CONFIG.anomaly.cooldownMin;
}
+__exports_src_runtimeSession_js["createRuntimeSession"] = createRuntimeSession;
+__exports_src_runtimeSession_js["restartRuntimeSession"] = restartRuntimeSession;
+__exports_src_runtimeSession_js["scheduleNextAnomalyAfterTrigger"] = scheduleNextAnomalyAfterTrigger;
+__exports_src_runtimeSession_js["scheduleNextAnomalyAfterRevive"] = scheduleNextAnomalyAfterRevive;
+}
+var createRuntimeSession = __exports_src_runtimeSession_js["createRuntimeSession"];
+var restartRuntimeSession = __exports_src_runtimeSession_js["restartRuntimeSession"];
+var scheduleNextAnomalyAfterTrigger = __exports_src_runtimeSession_js["scheduleNextAnomalyAfterTrigger"];
+var scheduleNextAnomalyAfterRevive = __exports_src_runtimeSession_js["scheduleNextAnomalyAfterRevive"];
// --- src/rewardGuard.js ---
+var __exports_src_rewardGuard_js = {};
+{
function shouldApplyReward(meta, currentRunToken, kind, state) {
if (meta?.context?.runToken !== currentRunToken || !state) return false;
@@ -1595,8 +2606,13 @@ function shouldApplyReward(meta, currentRunToken, kind, state) {
return false;
}
+__exports_src_rewardGuard_js["shouldApplyReward"] = shouldApplyReward;
+}
+var shouldApplyReward = __exports_src_rewardGuard_js["shouldApplyReward"];
// --- src/firstRunGuidance.js ---
+var __exports_src_firstRunGuidance_js = {};
+{
function getOperatorCue(state, nextAnomalyAt) {
const elapsed = Math.max(0, Math.floor(state?.elapsed ?? 0));
const firstAnomalySeen = (state?.anomaliesTriggeredTotal ?? 0) > 0;
@@ -1617,8 +2633,13 @@ function getOperatorCue(state, nextAnomalyAt) {
return '对得上就放行,对不上就封锁。';
}
+__exports_src_firstRunGuidance_js["getOperatorCue"] = getOperatorCue;
+}
+var getOperatorCue = __exports_src_firstRunGuidance_js["getOperatorCue"];
// --- platform/canvasLabels.js ---
+var __exports_platform_canvasLabels_js = {};
+{
function getCanvasLabels() {
const skin = getSkin();
@@ -1632,7 +2653,7 @@ function getCanvasLabels() {
actionPanel: canvas.actionPanel || '操作面板',
logPanel: canvas.logPanel || '系统日志',
failureTitle: canvas.failureTitle || '系统崩溃',
- failureEyebrow: canvas.failureEyebrow || 'SYSTEM FAILURE',
+ failureEyebrow: canvas.failureEyebrow || '系统故障',
revive: t('ui.viewAd'),
restart: t('ui.restart'),
revealTruth: t('ui.revealTruth'),
@@ -1665,8 +2686,19 @@ function getCanvasDirectionLabel(value) {
return labels[value] || value;
}
+__exports_platform_canvasLabels_js["getCanvasLabels"] = getCanvasLabels;
+__exports_platform_canvasLabels_js["getCanvasDecodedMonitorText"] = getCanvasDecodedMonitorText;
+__exports_platform_canvasLabels_js["getCanvasDoorLabel"] = getCanvasDoorLabel;
+__exports_platform_canvasLabels_js["getCanvasDirectionLabel"] = getCanvasDirectionLabel;
+}
+var getCanvasLabels = __exports_platform_canvasLabels_js["getCanvasLabels"];
+var getCanvasDecodedMonitorText = __exports_platform_canvasLabels_js["getCanvasDecodedMonitorText"];
+var getCanvasDoorLabel = __exports_platform_canvasLabels_js["getCanvasDoorLabel"];
+var getCanvasDirectionLabel = __exports_platform_canvasLabels_js["getCanvasDirectionLabel"];
// --- platform/canvasAssets.js ---
+var __exports_platform_canvasAssets_js = {};
+{
const CCTV_STATE_IDS = Object.freeze([
'00_idle_closed', '01_door_open', '02_door_opening', '03_door_closing',
'04_moving_up', '05_moving_down', '06_power_low', '07_power_outage',
@@ -1676,6 +2708,22 @@ const CCTV_STATE_IDS = Object.freeze([
'20_threat_high', '21_maintenance_mode', '22_system_reboot', '23_cooldown_safe',
]);
+const CCTV_STATE_ALIASES = Object.freeze({
+ // V5 内容描述“重复主体”,现有移动素材以影子主体表现同一类空间入侵;保留内容 ID,显式复用已发布图。
+ '14_duplicate_subject': '14_shadow_inside',
+});
+
+const V5_CCTV_ASSETS = Object.freeze({
+ protocolStart: 'visual/cctv/v5_00_protocol_start_mobile.png',
+ quick: 'visual/cctv/v5_01_quick_mobile.png',
+ investigation: 'visual/cctv/v5_02_investigation_mobile.png',
+ identity: 'visual/cctv/v5_03_identity_mobile.png',
+ classification: 'visual/cctv/v5_04_classification_mobile.png',
+ highRisk: 'visual/cctv/v5_05_high_risk_mobile.png',
+ protocolQuery: 'visual/cctv/v5_06_protocol_query_mobile.png',
+ debrief: 'visual/cctv/v5_07_debrief_mobile.png',
+});
+
const BUTTON_ASSETS = Object.freeze({
default: 'visual/buttons/btn_close_default.png',
recommended: 'visual/buttons/btn_up_recommended.png',
@@ -1698,7 +2746,11 @@ const OVERLAY_ASSETS = Object.freeze({
function getCanvasVisualAssetManifest() {
return {
- cctv: Object.fromEntries(CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`])),
+ cctv: Object.fromEntries([
+ ...CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`]),
+ ...Object.entries(CCTV_STATE_ALIASES).map(([id, target]) => [id, `visual/cctv/${target}_mobile.png`]),
+ ]),
+ v5Cctv: { ...V5_CCTV_ASSETS },
buttons: { ...BUTTON_ASSETS },
overlays: { ...OVERLAY_ASSETS },
};
@@ -1729,6 +2781,7 @@ function createCanvasAssetStore(imageFactory) {
function preload() {
for (const path of Object.values(manifest.cctv)) load(path);
+ for (const path of Object.values(manifest.v5Cctv)) load(path);
for (const path of Object.values(manifest.buttons)) load(path);
for (const path of Object.values(manifest.overlays)) load(path);
}
@@ -1742,6 +2795,7 @@ function createCanvasAssetStore(imageFactory) {
manifest,
preload,
getCctv: stateId => get(manifest.cctv[stateId] || manifest.cctv['00_idle_closed']),
+ getV5Cctv: screenId => get(manifest.v5Cctv[screenId] || manifest.v5Cctv.quick),
getButton: kind => get(manifest.buttons[kind] || manifest.buttons.default),
getOverlay: kind => get(manifest.overlays[kind]),
getStatus: () => ({
@@ -1752,8 +2806,15 @@ function createCanvasAssetStore(imageFactory) {
};
}
+__exports_platform_canvasAssets_js["getCanvasVisualAssetManifest"] = getCanvasVisualAssetManifest;
+__exports_platform_canvasAssets_js["createCanvasAssetStore"] = createCanvasAssetStore;
+}
+var getCanvasVisualAssetManifest = __exports_platform_canvasAssets_js["getCanvasVisualAssetManifest"];
+var createCanvasAssetStore = __exports_platform_canvasAssets_js["createCanvasAssetStore"];
// --- platform/miniGameClock.js ---
+var __exports_platform_miniGameClock_js = {};
+{
function createMiniGameClock(now = () => Date.now()) {
let started = false;
let paused = false;
@@ -1793,8 +2854,13 @@ function createMiniGameClock(now = () => Date.now()) {
};
}
+__exports_platform_miniGameClock_js["createMiniGameClock"] = createMiniGameClock;
+}
+var createMiniGameClock = __exports_platform_miniGameClock_js["createMiniGameClock"];
// --- platform/cctvMotion.js ---
+var __exports_platform_cctvMotion_js = {};
+{
const ACTION_DURATIONS = Object.freeze({
openDoor: 1000,
@@ -1954,8 +3020,13 @@ function createCctvMotionController(now = () => Date.now()) {
return { startAction, startAnomaly, sample, pause, resume, reset };
}
+__exports_platform_cctvMotion_js["createCctvMotionController"] = createCctvMotionController;
+}
+var createCctvMotionController = __exports_platform_cctvMotion_js["createCctvMotionController"];
// --- platform/miniGameAudio.js ---
+var __exports_platform_miniGameAudio_js = {};
+{
const SOURCES = Object.freeze({
click: 'audio/click.wav',
anomaly: 'audio/anomaly.wav',
@@ -1967,8 +3038,37 @@ const SOURCES = Object.freeze({
wrong: 'audio/wrong.wav',
});
+const MUSIC_SOURCES = Object.freeze({
+ calm: 'audio/bgm-night-shift-loop.wav',
+ pressure: 'audio/bgm-anomaly-pressure-loop.wav',
+});
+
+const V5_FEEDBACK_PROFILES = Object.freeze({
+ camera: Object.freeze({ cue: 'click', haptic: 'light' }),
+ 'tool:thermal': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'tool:replay': Object.freeze({ cue: 'motor', haptic: 'light' }),
+ 'tool:protocol': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'protocol:close': Object.freeze({ cue: 'release', haptic: 'light' }),
+ 'identity:verify': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'identity:correct': Object.freeze({ cue: 'release', haptic: 'medium' }),
+ 'identity:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'classification:enter': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'classification:correct': Object.freeze({ cue: 'lockdown', haptic: 'medium' }),
+ 'classification:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'highRisk:correct': Object.freeze({ cue: 'lockdown', haptic: 'heavy' }),
+ 'highRisk:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+});
+
+function getV5FeedbackProfile(kind) {
+ const profile = V5_FEEDBACK_PROFILES[kind] || V5_FEEDBACK_PROFILES.camera;
+ return { ...profile };
+}
+
function createMiniGameAudio(api) {
const contexts = new Map();
+ let musicContext = null;
+ let musicState = null;
+ let musicPaused = true;
let muted = false;
function getContext(cue) {
@@ -1983,7 +3083,27 @@ function createMiniGameAudio(api) {
return context;
}
- return {
+ function getMusicContext() {
+ if (musicContext) return musicContext;
+ if (!api || typeof api.createInnerAudioContext !== 'function') return null;
+ musicContext = api.createInnerAudioContext();
+ musicContext.autoplay = false;
+ musicContext.loop = true;
+ musicContext.volume = 0.12;
+ return musicContext;
+ }
+
+ function safePlay(context) {
+ try {
+ const result = context?.play?.();
+ result?.catch?.(() => {});
+ return Boolean(context && typeof context.play === 'function');
+ } catch {
+ return false;
+ }
+ }
+
+ const controller = {
play(cue) {
if (muted || !SOURCES[cue]) return false;
const context = getContext(cue);
@@ -1991,33 +3111,82 @@ function createMiniGameAudio(api) {
try {
context.stop?.();
context.seek?.(0);
- const result = context.play();
- result?.catch?.(() => {});
- return true;
+ return safePlay(context);
} catch {
return false;
}
},
+ setMusicState(nextState) {
+ if (!MUSIC_SOURCES[nextState]) return false;
+ musicState = nextState;
+ if (muted) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ if (context.src !== MUSIC_SOURCES[nextState]) {
+ context.stop?.();
+ context.src = MUSIC_SOURCES[nextState];
+ context.loop = true;
+ context.volume = nextState === 'pressure' ? 0.10 : 0.12;
+ context.seek?.(0);
+ }
+ musicPaused = false;
+ return safePlay(context);
+ },
+ pauseMusic() {
+ musicContext?.pause?.();
+ musicPaused = true;
+ },
+ resumeMusic() {
+ if (muted || !musicState || !musicPaused) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ context.src = MUSIC_SOURCES[musicState];
+ context.loop = true;
+ context.volume = musicState === 'pressure' ? 0.10 : 0.12;
+ musicPaused = false;
+ return safePlay(context);
+ },
+ stopMusic() {
+ musicContext?.stop?.();
+ musicPaused = true;
+ },
+ getMusicState() {
+ return musicState;
+ },
stopAll() {
for (const context of contexts.values()) context.stop?.();
+ controller.stopMusic();
},
destroy() {
for (const context of contexts.values()) context.destroy?.();
contexts.clear();
+ musicContext?.destroy?.();
+ musicContext = null;
+ musicState = null;
+ musicPaused = true;
},
setMuted(value) {
muted = Boolean(value);
- if (muted) this.stopAll();
+ if (muted) controller.stopAll();
return muted;
},
isMuted() {
return muted;
},
};
+
+ return controller;
}
+__exports_platform_miniGameAudio_js["getV5FeedbackProfile"] = getV5FeedbackProfile;
+__exports_platform_miniGameAudio_js["createMiniGameAudio"] = createMiniGameAudio;
+}
+var getV5FeedbackProfile = __exports_platform_miniGameAudio_js["getV5FeedbackProfile"];
+var createMiniGameAudio = __exports_platform_miniGameAudio_js["createMiniGameAudio"];
// --- platform/douyinIntegration.js ---
+var __exports_platform_douyinIntegration_js = {};
+{
function bindMiniGameLifecycle(api, handlers = {}) {
const onPause = () => handlers.onPause?.();
const onResume = (options) => handlers.onResume?.(options);
@@ -2079,8 +3248,17 @@ function navigateToDouyinSidebar(api) {
});
}
+__exports_platform_douyinIntegration_js["bindMiniGameLifecycle"] = bindMiniGameLifecycle;
+__exports_platform_douyinIntegration_js["checkDouyinSidebar"] = checkDouyinSidebar;
+__exports_platform_douyinIntegration_js["navigateToDouyinSidebar"] = navigateToDouyinSidebar;
+}
+var bindMiniGameLifecycle = __exports_platform_douyinIntegration_js["bindMiniGameLifecycle"];
+var checkDouyinSidebar = __exports_platform_douyinIntegration_js["checkDouyinSidebar"];
+var navigateToDouyinSidebar = __exports_platform_douyinIntegration_js["navigateToDouyinSidebar"];
// --- platform/canvasRenderer.js ---
+var __exports_platform_canvasRenderer_js = {};
+{
/**
* canvasRenderer.js — Canvas 渲染器
*
@@ -2136,24 +3314,38 @@ function getCanvasViewportMetrics(systemInfo = {}) {
}
function getCanvasLayout(height = 1334, safeTop = 0) {
- // V4:一块大监控、三项读数、一个双选任务。禁止把桌面后台缩进手机。
+ // V5:协议与 CAM 使用原生 Canvas 行;大 CCTV 仍是最大单一表面。
const topbar = { x: 14, y: 12 + safeTop, w: 722, h: 76 };
- const rule = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
- const monitorH = Math.max(520, Math.min(880, height - safeTop - 644));
- const monitor = { x: 14, y: 170 + safeTop, w: 722, h: monitorH };
+ const protocolBar = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
+ const cameraTabs = {
+ x: 14, y: 170 + safeTop, w: 722, h: 54, gap: 8,
+ hitY: 146 + safeTop, hitH: 94,
+ };
+ // Match the two official V5 portrait frames: 360×640 uses a compact 230px CCTV,
+ // while 393×852 spends the extra vertical room on a 360px CCTV. Interpolation
+ // keeps intermediate phones fluid without creating a dead area below the monitor.
+ const monitorH = Math.max(479, Math.min(687, 479 + (height - 1334) * (208 / 291)));
+ const monitor = { x: 14, y: 232 + safeTop, w: 722, h: monitorH };
const readings = { x: 14, y: monitor.y + monitor.h + 12, w: 722, h: 108 };
+ const tools = {
+ x: 14, y: readings.y + readings.h + 12, w: 722, h: 76, gap: 10,
+ hitY: readings.y + readings.h + 2, hitH: 100,
+ };
const actions = {
- x: 14, y: readings.y + readings.h + 12, w: 722, h: 220,
- columns: 2, gap: 14, buttonH: 164,
+ x: 14, y: tools.y + tools.h + 12, w: 722, h: 146,
+ columns: 2, gap: 14, buttonH: 104,
};
actions.startY = actions.y + 42;
actions.buttonW = (actions.w - 32 - actions.gap) / 2;
const feedbackY = actions.y + actions.h + 12;
return {
topbar,
- rule,
+ rule: protocolBar,
+ protocolBar,
+ cameraTabs,
monitor,
readings,
+ tools,
actions,
feedback: { x: 14, y: feedbackY, w: 722, h: Math.max(90, height - feedbackY - 18) },
};
@@ -2307,15 +3499,64 @@ function getRuleCopy(state) {
return t('ui.coreRule');
}
-function drawRuleStrip(state) {
- const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).rule;
+function getCanvasProtocolItems(state) {
+ return (state?.night?.activeProtocols || []).slice(0, 3).map(protocol => ({
+ id: protocol.id,
+ category: protocol.category || 'protocol',
+ text: protocol.text || protocol.id,
+ }));
+}
+
+function getCanvasProtocolSummary(protocols = []) {
+ return protocols.map((protocol, index) => {
+ const text = String(protocol?.text || protocol?.id || '');
+ const compact = text.length > 14 ? `${text.slice(0, 14)}…` : text;
+ return `${index + 1}.${compact}`;
+ }).join(' ');
+}
+
+function drawProtocolBar(state) {
+ const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).protocolBar;
drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.40)');
- const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
- ctx.fillStyle = guided ? COLORS.amber : COLORS.green;
+ const protocols = getCanvasProtocolItems(state);
+ ctx.fillStyle = protocols.length ? COLORS.amber : COLORS.green;
ctx.fillRect(x + 6, y + 6, 7, h - 12);
ctx.fillStyle = COLORS.text;
- ctx.font = '26px "Microsoft YaHei", sans-serif';
- ctx.fillText(getRuleCopy(state), x + 30, y + 43, w - 142);
+ ctx.font = 'bold 20px "Microsoft YaHei", sans-serif';
+ ctx.fillText('夜班协议', x + 28, y + 26);
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
+ const summary = guided || !protocols.length
+ ? getRuleCopy(state)
+ : getCanvasProtocolSummary(protocols);
+ ctx.fillText(summary, x + 28, y + 52, w - 52);
+}
+
+function getCanvasCameraTabs(state) {
+ const cameras = Object.keys(state?.night?.currentShift?.evidence?.cameras || {});
+ const activeCamera = state?.investigation?.activeCamera || 'cam01';
+ return ['cam01', 'cam03', 'cam07']
+ .filter(id => cameras.includes(id))
+ .map(id => ({ id, label: id.replace('cam', 'CAM-'), active: id === activeCamera }));
+}
+
+function drawCameraTabs(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const tabs = getCanvasCameraTabs(state);
+ if (!tabs.length) return;
+ const tabW = (layout.w - layout.gap * (tabs.length - 1)) / tabs.length;
+ tabs.forEach((tab, index) => {
+ const x = layout.x + index * (tabW + layout.gap);
+ roundRect(x, layout.y, tabW, layout.h, 2,
+ tab.active ? '#17352a' : '#101314',
+ tab.active ? 'rgba(121,214,163,0.78)' : 'rgba(195,200,190,0.24)');
+ ctx.fillStyle = tab.active ? COLORS.green : COLORS.muted;
+ ctx.font = 'bold 22px Consolas, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(tab.label, x + tabW / 2, layout.y + 35);
+ drawPressShade(x, layout.y, tabW, layout.h, getPressDepth(tab.id));
+ });
+ ctx.textAlign = 'left';
}
function getCanvasReadings(state, motion = null) {
@@ -2376,7 +3617,14 @@ function drawFeedback(state) {
ctx.textAlign = 'right';
ctx.fillText(pending ? '等待判断' : `安全 ${Math.round(state.stability || 0)}%`, x + w - 24, y + 42);
ctx.textAlign = 'left';
- const barY = y + Math.min(h - 22, 62);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const power = Math.max(0, Math.min(100, Math.round(Number(state.power) || 0)));
+ const contamination = Math.max(0, Math.min(100, Math.round(Number(state.contamination?.value) || 0)));
+ ctx.fillText(`电力 ${power}%`, x + 24, y + 70);
+ ctx.fillStyle = contamination >= 51 ? COLORS.red : contamination >= 26 ? COLORS.amber : COLORS.cyan;
+ ctx.fillText(`污染 ${contamination}%`, x + 168, y + 70);
+ const barY = y + Math.min(h - 22, 78);
roundRect(x + 24, barY, w - 48, 12, 2, 'rgba(255,255,255,0.08)');
if (!pending) {
roundRect(x + 24, barY, Math.max(0, (w - 48) * ((state.stability || 0) / 100)), 12, 2,
@@ -2513,12 +3761,22 @@ function getCanvasCctvTreatment(cctvState = '00_idle_closed') {
const entity = ['13_entity_near', '14_shadow_inside', '15_anomaly_wandering'].includes(cctvState);
const threat = ['08_emergency_stop', '09_door_jammed', '16_wrong_floor', '20_threat_high'].includes(cctvState);
const darkness = cctvState === '07_power_outage' ? 0.62 : cctvState === '10_signal_lost' ? 0.38 : 0;
+ const calm = cctvState === '19_stabilized' || cctvState === '23_cooldown_safe';
const tint = threat
? 'rgba(255,77,109,0.16)'
- : cctvState === '19_stabilized' || cctvState === '23_cooldown_safe'
+ : calm
? 'rgba(97,255,190,0.12)'
: 'rgba(97,255,190,0.05)';
- return { tint, darkness, entity, glitch, threat };
+ const border = threat
+ ? 'rgba(255,77,109,0.85)'
+ : glitch
+ ? 'rgba(225,168,75,0.62)'
+ : entity
+ ? 'rgba(178,132,255,0.62)'
+ : calm
+ ? 'rgba(97,255,190,0.52)'
+ : 'rgba(121,214,163,0.34)';
+ return { tint, darkness, entity, glitch, threat, border };
}
function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight = 420) {
@@ -2537,34 +3795,109 @@ function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight =
ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
}
-function drawCctvImage(image, x, y, w, h) {
- const sourceW = Number(image.width || image.naturalWidth) || 720;
- const sourceH = Number(image.height || image.naturalHeight) || 420;
- // 生产状态图顶部/底部烘焙了英文诊断和固定HUD;先裁掉答案区,再按主画面 cover。
- const cropTop = Math.min(58, sourceH * 0.14);
- const cropBottom = Math.min(30, sourceH * 0.08);
- const usableH = sourceH - cropTop - cropBottom;
- const sourceRatio = sourceW / usableH;
- const targetRatio = w / h;
- let sx = 0, sy = cropTop, sw = sourceW, sh = usableH;
- if (sourceRatio > targetRatio) {
- sw = usableH * targetRatio;
- sx = (sourceW - sw) / 2;
- } else {
- sh = sourceW / targetRatio;
- sy = cropTop + (usableH - sh) / 2;
+function drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime = 0) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+ ctx.clip();
+
+ // 真实监控感:扫描线 + 镜头暗角 + 轻微色偏。三层均只作用于 CCTV,不污染按钮和协议。
+ const scanlines = assetStore?.getOverlay('scanlines');
+ const vignette = assetStore?.getOverlay('vignette');
+ const frame = assetStore?.getOverlay('frame');
+ if (scanlines) {
+ ctx.globalAlpha = treatment.threat ? 0.38 : 0.24;
+ ctx.drawImage(scanlines, x, y, w, h);
}
- ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
+ if (vignette) {
+ ctx.globalAlpha = treatment.threat ? 0.82 : 0.62;
+ ctx.drawImage(vignette, x, y, w, h);
+ }
+ ctx.globalAlpha = 1;
+
+ if (treatment.tint) {
+ ctx.fillStyle = treatment.tint;
+ ctx.fillRect(x, y, w, h);
+ }
+
+ // 慢速 CRT 扫描带:比静态噪点更容易让玩家感到“摄像头正在工作”。
+ const phase = ((frameTime / 1800) % 1 + 1) % 1;
+ const beamY = y + phase * h;
+ const beam = ctx.createLinearGradient(x, beamY - 30, x, beamY + 30);
+ beam.addColorStop(0, 'rgba(97,255,190,0)');
+ beam.addColorStop(0.5, treatment.threat ? 'rgba(255,77,109,0.30)' : 'rgba(97,255,190,0.22)');
+ beam.addColorStop(1, 'rgba(97,255,190,0)');
+ ctx.fillStyle = beam;
+ ctx.fillRect(x, beamY - 30, w, 60);
+
+ // 录制指示器与镜头角标是运行时 HUD,不泄露答案,只建立“夜班监控”语境。
+ const pulse = 0.72 + Math.sin(frameTime / 170) * 0.22;
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = treatment.threat ? COLORS.red : '#ff5d67';
+ ctx.beginPath();
+ ctx.arc(x + 20, y + 22, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#e4e8df';
+ ctx.font = 'bold 16px Consolas, monospace';
+ ctx.fillText('REC', x + 32, y + 28);
+ ctx.fillStyle = treatment.threat ? '#ff9a9f' : '#b4c4bb';
+ ctx.font = '14px Consolas, monospace';
+ const activeCamera = String(state?.investigation?.activeCamera || 'cam01').toUpperCase().replace('CAM', 'CAM-');
+ ctx.fillText(`${activeCamera} // NIGHT WATCH`, x + 20, y + h - 18);
+
+ // 角框比一整圈发光边框更克制,但会让 CCTV 从“普通图片”变成监控窗口。
+ if (frame) {
+ ctx.globalAlpha = 0.72;
+ ctx.drawImage(frame, x, y, w, h);
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = treatment.border || 'rgba(121,214,163,0.44)';
+ ctx.lineWidth = treatment.threat ? 3 + Math.max(0, Math.sin(frameTime / 130)) : 2;
+ ctx.strokeRect(x + 2, y + 2, w - 4, h - 4);
+
+ if (treatment.threat) {
+ ctx.globalAlpha = 0.72 + Math.sin(frameTime / 110) * 0.18;
+ ctx.strokeStyle = COLORS.red;
+ ctx.lineWidth = 4;
+ ctx.strokeRect(x + 8, y + 8, w - 16, h - 16);
+ ctx.globalAlpha = 1;
+ }
+ ctx.restore();
+}
+
+function drawCctvImage(image, x, y, w, h) {
+ // CCTV 窗口保持主布局尺寸;素材 cover 铺满窗口:无拉伸变形、无黑边、不叠第二层背景。
+ // 高竖屏窗口下中央裁掉两侧边缘,轿厢主体始终居中完整。
+ drawImageCover(image, x, y, w, h);
+}
+
+// V5 阶段场景映射:夜班各回合使用交接包对应场景,运动/异常瞬时态仍回退 24 状态机图。
+function getV5CctvScreenId(state) {
+ if (state?.night?.overlay === 'protocolQuery') return 'protocolQuery';
+ const roundType = state?.night?.roundType;
+ if (!state?.night?.currentShift) return null;
+ return {
+ quick: 'quick',
+ investigation: 'investigation',
+ identity: 'identity',
+ classification: 'classification',
+ highRisk: 'highRisk',
+ }[roundType] || null;
}
function drawCctvScene(state, x, y, w, h, motion = null) {
if (h <= 20) return;
const baseVisual = deriveVisualState(state);
const frameTime = Number(motion?.frameTime ?? Date.now());
- const cctvState = motion?.cctvState || baseVisual.cctvState;
+ const cctvState = motion?.cctvState
+ || state?.night?.currentShift?.visualState
+ || baseVisual.cctvState;
const visual = { ...baseVisual, cctvState, glitch: baseVisual.glitch || Number(motion?.glitchAlpha || 0) > 0 };
const treatment = getCanvasCctvTreatment(cctvState);
- const sceneImage = assetStore?.getCctv(cctvState);
+ const v5ScreenId = motion?.active ? null : getV5CctvScreenId(state);
+ const sceneImage = (v5ScreenId ? assetStore?.getV5Cctv(v5ScreenId) : null)
+ || assetStore?.getCctv(cctvState);
if (sceneImage) {
ctx.save();
@@ -2589,18 +3922,32 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
drawCctvImage(sceneImage, drawX, drawY, drawW, drawH);
ctx.globalAlpha = 1;
+ drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime);
+
// 状态图已内置基础监控纹理,只叠加真正随时间变化的警报与干扰。
const pendingDecision = state.inspection?.status === 'pending';
const alert = treatment.threat && !pendingDecision ? assetStore.getOverlay('redAlert') : null;
const glitchOverlay = treatment.glitch ? assetStore.getOverlay('glitch') : null;
- const sweep = state.inspection?.status === 'pending' ? assetStore.getOverlay('sweep') : null;
- for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36], [sweep, 0.28]]) {
+ for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36]]) {
if (!image) continue;
ctx.globalAlpha = alpha;
ctx.drawImage(image, x, y, w, h);
}
ctx.globalAlpha = 1;
+ // 待判定扫描光束随时间自上而下扫过,给出“系统正在核对”的活体感。
+ const sweep = pendingDecision ? assetStore.getOverlay('sweep') : null;
+ if (sweep) {
+ const sweepH = Math.max(96, Math.floor(h * 0.38));
+ const sweepPhase = (frameTime / 2100) % 1.45;
+ if (sweepPhase <= 1) {
+ const sweepY = y - sweepH + sweepPhase * (h + sweepH * 2);
+ ctx.globalAlpha = 0.34;
+ ctx.drawImage(sweep, x, sweepY, w, sweepH);
+ ctx.globalAlpha = 1;
+ }
+ }
+
const glitchAlpha = Math.max(0, Math.min(1, Number(motion?.glitchAlpha || 0)));
if (glitchAlpha > 0) {
ctx.globalAlpha = glitchAlpha;
@@ -2628,23 +3975,8 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
ctx.fillStyle = scanGradient;
ctx.fillRect(x, scanY - 24, w, 48);
- // 实体式顶部遮光罩:覆盖素材中烘焙的 07 / STABILIZED / 英文诊断,而不是再贴一块中央黑卡。
- const hudShade = ctx.createLinearGradient(0, y, 0, y + 104);
- hudShade.addColorStop(0, '#020707');
- hudShade.addColorStop(0.82, '#020707');
- hudShade.addColorStop(1, 'rgba(2,7,7,0)');
- ctx.fillStyle = hudShade;
- ctx.fillRect(x, y, w, 112);
- ctx.strokeStyle = 'rgba(121,214,163,0.22)';
- ctx.beginPath();
- ctx.moveTo(x, y + 96);
- ctx.lineTo(x + w, y + 96);
- ctx.stroke();
-
- // 状态图含固定英文诊断与固定楼层;源图已裁掉烘焙答案区,这里只叠加中文运行时状态。
+ // 替换图无烘焙 HUD;只绘制运行时楼层和状态标签,不覆盖电梯主体。
const inspectionPending = state.inspection?.status === 'pending';
- const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
- const floorDiscrepancy = ['phantom_floor', 'floor_jump', 'negative_floor'].includes(activeId);
const neutralBorder = inspectionPending ? 'rgba(195,200,190,0.34)' : treatment.border;
ctx.strokeStyle = neutralBorder;
ctx.globalAlpha = 0.72;
@@ -2844,12 +4176,65 @@ function getCanvasActionButtons(state) {
return operations;
}
+const TOOL_LABELS = {
+ thermal: '热源扫描',
+ replay: '三秒回放',
+ protocol: '夜班协议',
+};
+
+function getCanvasToolButtons(state) {
+ const investigation = state?.investigation || {};
+ return ['thermal', 'replay', 'protocol'].map(id => {
+ const tool = investigation.tools?.[id] || {};
+ const remaining = tool.remaining;
+ const unlimited = !Number.isFinite(remaining);
+ const disabled = !unlimited && (remaining <= 0 || (investigation.power ?? 0) < (tool.powerCost || 0));
+ return {
+ id,
+ label: TOOL_LABELS[id],
+ meta: unlimited ? '不限次' : `${remaining || 0}次 · ${tool.powerCost || 0}电`,
+ disabled,
+ };
+ });
+}
+
+const ROUND_ACTIONS = {
+ quick: [
+ { id: 'release', label: '放行', sublabel: '画面数据一致', decision: 'normal' },
+ { id: 'lockdown', label: '封锁', sublabel: '发现任意矛盾', decision: 'anomaly' },
+ ],
+ investigation: [
+ { id: 'markSuspicion', label: '标记疑点', sublabel: '保留当前证据' },
+ { id: 'enterClassification', label: '进入分类', sublabel: '提交异常类型' },
+ ],
+ identity: [
+ { id: 'identityRelease', label: '放行', sublabel: '身份一致' },
+ { id: 'identityReject', label: '拒绝', sublabel: '身份冲突' },
+ { id: 'identityVerify', label: '核验', sublabel: '查看胸牌与权限' },
+ ],
+ classification: [
+ { id: 'classify:person', label: '人物', sublabel: '身份/外观' },
+ { id: 'classify:quantity', label: '数量', sublabel: '人数/载重' },
+ { id: 'classify:space', label: '空间', sublabel: '楼层/位置' },
+ { id: 'classify:time', label: '时间', sublabel: '时序/回放' },
+ { id: 'classify:device', label: '设备', sublabel: '信号/读数' },
+ { id: 'classify:dynamic', label: '动态', sublabel: '移动/变化' },
+ ],
+ highRisk: [
+ { id: 'highRisk:emergencyStop', label: '急停', sublabel: '消耗 15 电' },
+ { id: 'highRisk:restart', label: '重启', sublabel: '消耗 10 电' },
+ { id: 'highRisk:lockdownFloor', label: '封锁楼层', sublabel: '消耗 12 电' },
+ ],
+};
+
function getCanvasVisibleActionButtons(state) {
if (state.inspection?.status === 'pending') {
- return [
+ if (Number(state.tutorialStep || 0) < 2) return [
{ id: 'reportNormal', label: t('ui.reportNormal'), sublabel: '画面数据一致', decision: 'normal' },
{ id: 'reportAnomaly', label: t('ui.reportAnomaly'), sublabel: '发现任意矛盾', decision: 'anomaly' },
];
+ if (Number(state.tutorialStep || 0) === 3) return ROUND_ACTIONS.quick;
+ return ROUND_ACTIONS[state?.night?.roundType] || ROUND_ACTIONS.quick;
}
const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
@@ -2860,6 +4245,28 @@ function getCanvasVisibleActionButtons(state) {
return [{ id: 'standby', label: t('ui.standby'), sublabel: '监控自动运行', disabled: true, wide: true }];
}
+function drawTools(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).tools;
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (layout.w - 24 - layout.gap * 2) / 3;
+ tools.forEach((tool, index) => {
+ const x = layout.x + 12 + index * (buttonW + layout.gap);
+ ctx.save();
+ if (tool.disabled) ctx.globalAlpha = 0.42;
+ roundRect(x, layout.y, buttonW, layout.h, 2, '#101716', tool.disabled ? COLORS.line : 'rgba(132,185,176,0.62)');
+ ctx.fillStyle = tool.disabled ? COLORS.muted : COLORS.cyan;
+ ctx.font = 'bold 22px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(tool.label, x + buttonW / 2, layout.y + 31);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '18px "Microsoft YaHei", sans-serif';
+ ctx.fillText(tool.meta, x + buttonW / 2, layout.y + 58);
+ drawPressShade(x, layout.y, buttonW, layout.h, getPressDepth(tool.id));
+ ctx.restore();
+ });
+ ctx.textAlign = 'left';
+}
+
// ── 绘制操作按钮 ──
function drawActions(state) {
const layout = getCanvasLayout(DH, safeInsetTop).actions;
@@ -2870,8 +4277,8 @@ function drawActions(state) {
ctx.fillText(state.activeAnomaly && state.inspection?.status !== 'pending' ? '系统处置' : '当前判断', x + 24, y + 31);
const btns = getCanvasVisibleActionButtons(state);
- const columns = btns.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? w - 32 : (w - 32 - gap) / 2;
+ const columns = btns.length === 1 ? 1 : btns.length === 6 ? 6 : btns.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? w - 32 : (w - 32 - gap * (columns - 1)) / columns;
btns.forEach((btn, i) => {
ctx.save();
if (btn.disabled) ctx.globalAlpha = 0.48;
@@ -2903,17 +4310,18 @@ function drawActions(state) {
ctx.shadowBlur = btn.disabled ? 0 : 12;
ctx.fillStyle = accent;
ctx.beginPath();
- ctx.arc(bx + buttonW / 2, by + 31, 9, 0, Math.PI * 2);
+ ctx.arc(bx + buttonW / 2, by + 18, 7, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = COLORS.text;
- ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.font = 'bold 28px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
- ctx.fillText(btn.label, bx + buttonW / 2, by + 94);
+ ctx.fillText(btn.label, bx + buttonW / 2, by + 57);
ctx.fillStyle = '#b5b8b1';
- ctx.font = '24px "Microsoft YaHei", sans-serif';
- ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 132);
+ ctx.font = '19px "Microsoft YaHei", sans-serif';
+ ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 86, buttonW - 20);
+ drawPressShade(bx, by, buttonW, buttonH, getPressDepth(btn.id));
const guidedIndex = Number(state.tutorialStep || 0);
const guided = (state.inspection?.status === 'pending'
@@ -2960,6 +4368,59 @@ function drawLogs(state) {
});
}
+function getCanvasOverlayCloseButton(height = 1334, safeTop = 0) {
+ const x = 55, w = 640, h = 430;
+ const y = Math.max(150 + safeTop, (height - h) / 2);
+ return { x: x + 32, y: y + h - 88, w: w - 64, h: 60 };
+}
+
+function getCanvasOverlayModel(state) {
+ if (state?.night?.overlay === 'protocolQuery') {
+ return {
+ type: 'protocolQuery',
+ title: '夜班协议查询',
+ lines: (state.night.protocolQuery || []).map(item => item.text || item.id),
+ action: 'closeOverlay',
+ };
+ }
+ if (state?.night?.overlay === 'debrief' && state.night.debrief) {
+ const { summary = {}, ending = {} } = state.night.debrief;
+ return {
+ type: 'debrief',
+ title: `局后复盘 · ${ending.name || '未决记录'}`,
+ lines: [
+ `判断 ${summary.decisions || 0} 次 · 准确率 ${Math.round((summary.accuracy || 0) * 100)}%`,
+ `污染峰值 ${summary.peakContamination || 0}`,
+ ending.summary || '',
+ ].filter(Boolean),
+ action: 'closeOverlay',
+ };
+ }
+ return null;
+}
+
+function drawNightOverlay(state) {
+ const model = getCanvasOverlayModel(state);
+ if (!model) return;
+ ctx.fillStyle = 'rgba(0,0,0,0.76)';
+ ctx.fillRect(0, 0, DW, DH);
+ const x = 55, w = 640, h = 430, y = Math.max(150 + safeInsetTop, (DH - h) / 2);
+ drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.72)');
+ ctx.fillStyle = COLORS.amber;
+ ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.fillText(model.title, x + 32, y + 58, w - 64);
+ ctx.fillStyle = COLORS.text;
+ ctx.font = '26px "Microsoft YaHei", sans-serif';
+ model.lines.forEach((line, index) => wrapText(`${index + 1}. ${line}`, x + 32, y + 112 + index * 62, w - 64, 32));
+ const closeButton = getCanvasOverlayCloseButton(DH, safeInsetTop);
+ roundRect(closeButton.x, closeButton.y, closeButton.w, closeButton.h, 3, '#17352a', 'rgba(121,214,163,0.72)');
+ ctx.fillStyle = COLORS.green;
+ ctx.font = 'bold 26px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('返回监控', closeButton.x + closeButton.w / 2, closeButton.y + 39);
+ ctx.textAlign = 'left';
+}
+
// ── 绘制失败弹窗 ──
function drawFailureOverlay(state) {
if (!state.gameOver) return;
@@ -3180,11 +4641,42 @@ function wrapText(text, x, y, maxWidth, lineHeight) {
}
}
+// ── 按压反馈 ──
+const pressFx = new Map();
+const PRESS_FX_MS = 180;
+
+function noteCanvasPress(id) {
+ if (id) pressFx.set(id, Date.now());
+}
+
+function getPressDepth(id) {
+ const at = pressFx.get(id);
+ if (!Number.isFinite(at)) return 0;
+ const age = Date.now() - at;
+ if (age > PRESS_FX_MS) {
+ pressFx.delete(id);
+ return 0;
+ }
+ return 1 - age / PRESS_FX_MS;
+}
+
+function drawPressShade(x, y, w, h, depth) {
+ if (depth <= 0) return;
+ ctx.save();
+ ctx.globalAlpha = 0.3 * depth;
+ roundRect(x + 2, y + 2, w - 4, h - 4, 2, '#000000');
+ ctx.globalAlpha = 0.5 * depth;
+ ctx.strokeStyle = 'rgba(255,255,255,0.75)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(x + 5, y + 5, w - 10, h - 10);
+ ctx.restore();
+}
+
// ── 点击检测 ──
let clickHandlers = {};
function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
- const { onAdRevive, onRestart, onAction, onDecision, onToggleMute, onStart, onSidebar } = callbacks;
+ const { onAdRevive, onRestart, onAction, onDecision, onTool, onCameraSwitch, onToggleMute, onStart, onSidebar } = callbacks;
const inside = (rect) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
const muteControl = getCanvasMuteControl(DH, safeInsetTop, viewState.started !== false);
if (!state.gameOver && inside(muteControl)) {
@@ -3201,6 +4693,11 @@ function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
if (viewState.paused === true) return;
+ if (getCanvasOverlayModel(state)) {
+ if (inside(getCanvasOverlayCloseButton(DH, safeInsetTop))) onAction?.('closeOverlay');
+ return;
+ }
+
// 失败弹窗按钮检测
if (state.gameOver) {
const cardW = 640, cardH = 520;
@@ -3233,16 +4730,47 @@ function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
return;
}
- // V4 双选任务点击检测,与绘制布局共用同一组按钮数据。
+ const cameraLayout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const cameraTabs = getCanvasCameraTabs(state);
+ const cameraHit = { ...cameraLayout, y: cameraLayout.hitY ?? cameraLayout.y, h: cameraLayout.hitH ?? cameraLayout.h };
+ if (cameraTabs.length && inside(cameraHit)) {
+ const tabW = (cameraLayout.w - cameraLayout.gap * (cameraTabs.length - 1)) / cameraTabs.length;
+ for (let index = 0; index < cameraTabs.length; index += 1) {
+ const tabX = cameraLayout.x + index * (tabW + cameraLayout.gap);
+ if (x >= tabX && x <= tabX + tabW) {
+ noteCanvasPress(cameraTabs[index].id);
+ onCameraSwitch?.(cameraTabs[index].id);
+ }
+ }
+ return;
+ }
+
+ const toolLayout = getCanvasLayout(DH, safeInsetTop).tools;
+ const toolHit = { ...toolLayout, y: toolLayout.hitY ?? toolLayout.y, h: toolLayout.hitH ?? toolLayout.h };
+ if (inside(toolHit)) {
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (toolLayout.w - 24 - toolLayout.gap * 2) / 3;
+ for (let index = 0; index < tools.length; index += 1) {
+ const toolX = toolLayout.x + 12 + index * (buttonW + toolLayout.gap);
+ if (x >= toolX && x <= toolX + buttonW && !tools[index].disabled) {
+ noteCanvasPress(tools[index].id);
+ onTool?.(tools[index].id);
+ }
+ }
+ return;
+ }
+
+ // V5 动态任务点击检测,与绘制布局共用同一组按钮数据。
const layout = getCanvasLayout(DH, safeInsetTop).actions;
const buttons = getCanvasVisibleActionButtons(state);
- const columns = buttons.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap) / 2;
+ const columns = buttons.length === 1 ? 1 : buttons.length === 6 ? 6 : buttons.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap * (columns - 1)) / columns;
for (let i = 0; i < buttons.length; i += 1) {
const bx = layout.x + 16 + (i % columns) * (buttonW + layout.gap);
const by = layout.startY;
if (x >= bx && x <= bx + buttonW && y >= by && y <= by + layout.buttonH) {
if (buttons[i].disabled) return;
+ noteCanvasPress(buttons[i].id);
if (buttons[i].decision) {
onDecision?.(buttons[i].decision);
} else onAction?.(buttons[i].id);
@@ -3257,19 +4785,22 @@ function render(state, viewState = { started: true, paused: false }) {
drawBackground();
drawTopbar(state);
- drawRuleStrip(state);
+ drawProtocolBar(state);
+ drawCameraTabs(state);
drawMonitor(state, viewState.cctvMotion);
drawReadings(state, viewState.cctvMotion);
+ drawTools(state);
drawActions(state);
drawFeedback(state);
drawFailureOverlay(state);
+ drawNightOverlay(state);
if (viewState.started === false) drawStartOverlay(viewState);
else if (viewState.paused === true) drawPauseOverlay();
if (!state.gameOver) drawMuteControl(viewState);
}
// ── 初始化 ──
-function init(canvasEl, systemInfo = {}) {
+function init(canvasEl, systemInfo = {}, options = {}) {
canvas = canvasEl;
ctx = canvas.getContext('2d');
@@ -3282,20 +4813,73 @@ function init(canvasEl, systemInfo = {}) {
canvas.height = metrics.height;
scale = 1;
- const imageFactory = () => {
+ // 小游戏运行时优先 wx/tt createImage;浏览器验收 harness 通过 options.imageFactory 注入 DOM Image,
+ // 使发布 bundle 不含任何 document/window 引用。
+ const imageFactory = options.imageFactory || (() => {
if (typeof tt !== 'undefined' && typeof tt.createImage === 'function') return tt.createImage();
if (typeof wx !== 'undefined' && typeof wx.createImage === 'function') return wx.createImage();
if (typeof canvas.createImage === 'function') return canvas.createImage();
return null;
- };
+ });
assetStore = createCanvasAssetStore(imageFactory);
assetStore.preload();
return { width: DW, height: DH };
}
+__exports_platform_canvasRenderer_js["getCanvasViewportMetrics"] = getCanvasViewportMetrics;
+__exports_platform_canvasRenderer_js["getCanvasLayout"] = getCanvasLayout;
+__exports_platform_canvasRenderer_js["getCanvasStartControls"] = getCanvasStartControls;
+__exports_platform_canvasRenderer_js["getCanvasMuteControl"] = getCanvasMuteControl;
+__exports_platform_canvasRenderer_js["getCanvasStaticLabels"] = getCanvasStaticLabels;
+__exports_platform_canvasRenderer_js["getCanvasFailureOverlayCopy"] = getCanvasFailureOverlayCopy;
+__exports_platform_canvasRenderer_js["getCanvasProtocolItems"] = getCanvasProtocolItems;
+__exports_platform_canvasRenderer_js["getCanvasProtocolSummary"] = getCanvasProtocolSummary;
+__exports_platform_canvasRenderer_js["getCanvasCameraTabs"] = getCanvasCameraTabs;
+__exports_platform_canvasRenderer_js["getCanvasReadings"] = getCanvasReadings;
+__exports_platform_canvasRenderer_js["getCanvasStatusItems"] = getCanvasStatusItems;
+__exports_platform_canvasRenderer_js["getCanvasMeterBars"] = getCanvasMeterBars;
+__exports_platform_canvasRenderer_js["getCanvasCctvTreatment"] = getCanvasCctvTreatment;
+__exports_platform_canvasRenderer_js["getV5CctvScreenId"] = getV5CctvScreenId;
+__exports_platform_canvasRenderer_js["getCanvasActionButtons"] = getCanvasActionButtons;
+__exports_platform_canvasRenderer_js["getCanvasToolButtons"] = getCanvasToolButtons;
+__exports_platform_canvasRenderer_js["getCanvasVisibleActionButtons"] = getCanvasVisibleActionButtons;
+__exports_platform_canvasRenderer_js["getCanvasVisibleLogs"] = getCanvasVisibleLogs;
+__exports_platform_canvasRenderer_js["getCanvasOverlayCloseButton"] = getCanvasOverlayCloseButton;
+__exports_platform_canvasRenderer_js["getCanvasOverlayModel"] = getCanvasOverlayModel;
+__exports_platform_canvasRenderer_js["noteCanvasPress"] = noteCanvasPress;
+__exports_platform_canvasRenderer_js["onCanvasClick"] = onCanvasClick;
+__exports_platform_canvasRenderer_js["render"] = render;
+__exports_platform_canvasRenderer_js["init"] = init;
+}
+var getCanvasViewportMetrics = __exports_platform_canvasRenderer_js["getCanvasViewportMetrics"];
+var getCanvasLayout = __exports_platform_canvasRenderer_js["getCanvasLayout"];
+var getCanvasStartControls = __exports_platform_canvasRenderer_js["getCanvasStartControls"];
+var getCanvasMuteControl = __exports_platform_canvasRenderer_js["getCanvasMuteControl"];
+var getCanvasStaticLabels = __exports_platform_canvasRenderer_js["getCanvasStaticLabels"];
+var getCanvasFailureOverlayCopy = __exports_platform_canvasRenderer_js["getCanvasFailureOverlayCopy"];
+var getCanvasProtocolItems = __exports_platform_canvasRenderer_js["getCanvasProtocolItems"];
+var getCanvasProtocolSummary = __exports_platform_canvasRenderer_js["getCanvasProtocolSummary"];
+var getCanvasCameraTabs = __exports_platform_canvasRenderer_js["getCanvasCameraTabs"];
+var getCanvasReadings = __exports_platform_canvasRenderer_js["getCanvasReadings"];
+var getCanvasStatusItems = __exports_platform_canvasRenderer_js["getCanvasStatusItems"];
+var getCanvasMeterBars = __exports_platform_canvasRenderer_js["getCanvasMeterBars"];
+var getCanvasCctvTreatment = __exports_platform_canvasRenderer_js["getCanvasCctvTreatment"];
+var getV5CctvScreenId = __exports_platform_canvasRenderer_js["getV5CctvScreenId"];
+var getCanvasActionButtons = __exports_platform_canvasRenderer_js["getCanvasActionButtons"];
+var getCanvasToolButtons = __exports_platform_canvasRenderer_js["getCanvasToolButtons"];
+var getCanvasVisibleActionButtons = __exports_platform_canvasRenderer_js["getCanvasVisibleActionButtons"];
+var getCanvasVisibleLogs = __exports_platform_canvasRenderer_js["getCanvasVisibleLogs"];
+var getCanvasOverlayCloseButton = __exports_platform_canvasRenderer_js["getCanvasOverlayCloseButton"];
+var getCanvasOverlayModel = __exports_platform_canvasRenderer_js["getCanvasOverlayModel"];
+var noteCanvasPress = __exports_platform_canvasRenderer_js["noteCanvasPress"];
+var onCanvasClick = __exports_platform_canvasRenderer_js["onCanvasClick"];
+var render = __exports_platform_canvasRenderer_js["render"];
+var init = __exports_platform_canvasRenderer_js["init"];
// --- platform/miniGameRuntime.js ---
+var __exports_platform_miniGameRuntime_js = {};
+{
/**
* miniGameRuntime.js — 微信/抖音小游戏 Canvas 运行时入口
*
@@ -3422,6 +5006,11 @@ function startMiniGame() {
const vibrate = (type = 'light') => {
try { api.vibrateShort?.({ type }); } catch { /* optional haptics */ }
};
+ const playV5Feedback = (kind) => {
+ const profile = getV5FeedbackProfile(kind);
+ audio.play(profile.cue);
+ vibrate(profile.haptic);
+ };
const audioStorageKey = 'minigame_audio_muted_v1';
try {
audio.setMuted(api.getStorageSync?.(audioStorageKey) === true);
@@ -3434,7 +5023,7 @@ function startMiniGame() {
return available;
});
refreshSidebarAvailability();
- let session = createRuntimeSession();
+ let session = createRuntimeSession({ content: __V5_CONTENT__ });
let state = session.state;
let nextAnomalyAt = session.nextAnomalyAt;
let lastSnapshotAt = 0;
@@ -3456,6 +5045,7 @@ function startMiniGame() {
if (!lifecycleHidden) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
}
@@ -3524,6 +5114,9 @@ function startMiniGame() {
function toggleMute() {
const muted = audio.setMuted(!audio.isMuted());
+ if (!muted && clock.isStarted() && !state.gameOver && !lifecycleHidden && !adPauseActive) {
+ audio.resumeMusic() || audio.setMusicState(state.activeAnomaly ? 'pressure' : 'calm');
+ }
try {
api.setStorageSync?.(audioStorageKey, muted);
} catch {
@@ -3534,6 +5127,7 @@ function startMiniGame() {
function start() {
if (clock.isStarted()) return;
audio.play('boot');
+ audio.setMusicState('calm');
state = openInspection(state, {
id: `baseline-${runToken}`,
kind: 'normal',
@@ -3553,8 +5147,9 @@ function startMiniGame() {
function restart() {
runToken += 1;
audio.play('boot');
+ audio.setMusicState('calm');
clock.start();
- session = restartRuntimeSession({ state });
+ session = restartRuntimeSession({ state }, { content: __V5_CONTENT__ });
state = session.state;
cctvMotion.reset();
state = openInspection(state, {
@@ -3569,6 +5164,22 @@ function startMiniGame() {
failureRecorded = false;
}
+ function openScheduledNightInspection(nextState) {
+ const shift = nextState.night?.currentShift;
+ if (!shift) return nextState;
+ return openInspection(nextState, {
+ id: `night-${shift.id}-${nextState.night.shiftIndex}`,
+ kind: shift.shiftKind === 'anomaly' || shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ title: shift.name || shift.id,
+ duration: shift.duration ?? 10,
+ });
+ }
+
+ function scheduleFollowingNightShift(currentState, outcome) {
+ const advanced = advanceCurrentNightEventChain(currentState, __V5_CONTENT__, outcome);
+ return openScheduledNightInspection(scheduleNextNightShift(advanced.state, __V5_CONTENT__));
+ }
+
function resolveActiveAnomalyAutomatically(feedbackKey) {
if (!state.activeAnomaly) return false;
const automaticAction = getAnomalyResolutionAction(state.activeAnomaly);
@@ -3626,13 +5237,70 @@ function startMiniGame() {
}
// 教学第二班必须直接进入异常,不允许中间插入随机正常巡检。
const tutorialStep = Number(state.tutorialStep || 0);
+ if (tutorialStep === 4 && state.night?.activeEventChainId) {
+ state = openScheduledNightInspection(scheduleNextNightShift(state, __V5_CONTENT__));
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ return;
+ }
nextNormalInspectionAt = tutorialStep === 1
? Number.POSITIVE_INFINITY
: state.elapsed + (tutorialStep === 3 ? 2 : 4);
}
function handleAction(actionId) {
- if (state.gameOver) return;
+ if (state.gameOver && actionId !== 'closeOverlay') return;
+ if (actionId === 'closeOverlay') {
+ state = closeProtocolQuery(state);
+ playV5Feedback('protocol:close');
+ return;
+ }
+ if (actionId === 'identityVerify') {
+ const result = verifyCurrentIdentity(state);
+ if (!result.accepted) {
+ playV5Feedback('identity:wrong');
+ return;
+ }
+ state = result.state;
+ playV5Feedback('identity:verify');
+ return;
+ }
+ if (actionId === 'identityRelease' || actionId === 'identityReject') {
+ const result = resolveIdentityDecision(state, actionId === 'identityRelease' ? 'release' : 'reject');
+ if (!result.accepted) return;
+ playV5Feedback(`identity:${result.correct ? 'correct' : 'wrong'}`);
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ return;
+ }
+ if (actionId === 'enterClassification' || actionId === 'markSuspicion') {
+ state = {
+ ...state,
+ night: { ...state.night, roundType: 'classification' },
+ lastFeedback: '请选择异常分类',
+ };
+ playV5Feedback('classification:enter');
+ return;
+ }
+ if (actionId.startsWith('classify:')) {
+ const result = classifyCurrentShift(state, actionId.slice('classify:'.length));
+ if (!result.accepted) return;
+ state = result.state;
+ playV5Feedback(`classification:${result.correct ? 'correct' : 'wrong'}`);
+ if (state.night.roundType !== 'highRisk') {
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ }
+ return;
+ }
+ if (actionId.startsWith('highRisk:')) {
+ const result = resolveCurrentHighRisk(state, actionId.slice('highRisk:'.length));
+ if (!result.accepted) {
+ playV5Feedback('highRisk:wrong');
+ return;
+ }
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ playV5Feedback(`highRisk:${result.correct ? 'correct' : 'wrong'}`);
+ return;
+ }
if (actionId === 'unlockHiddenLog') {
decodeAd({ runToken });
return;
@@ -3650,6 +5318,42 @@ function startMiniGame() {
}
}
+ function handleCameraSwitch(cameraId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = switchCamera(state.investigation, cameraId, {
+ ...shift,
+ cameras: Object.keys(shift.evidence?.cameras || {}),
+ });
+ if (!result.accepted) return;
+ state = { ...state, investigation: result.state };
+ playV5Feedback('camera');
+ }
+
+ function handleTool(toolId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = useInvestigationTool(state.investigation, toolId, shift);
+ if (!result.accepted) {
+ audio.play('wrong');
+ vibrate('heavy');
+ return;
+ }
+ const count = Array.isArray(result.discoveredEvidence)
+ ? result.discoveredEvidence.length
+ : result.discoveredEvidence ? 1 : 0;
+ state = {
+ ...state,
+ investigation: result.state,
+ power: result.state.power,
+ lastFeedback: toolId === 'protocol'
+ ? `已调取 ${count} 条当前夜班协议`
+ : `${toolId === 'thermal' ? '热源扫描' : '三秒回放'}发现 ${count} 条证据`,
+ };
+ if (toolId === 'protocol') state = openProtocolQuery(state);
+ playV5Feedback(`tool:${toolId}`);
+ }
+
function handleAd(kind) {
if (kind === 'truth') {
truthAd({ runToken });
@@ -3667,6 +5371,8 @@ function startMiniGame() {
onCanvasClick(x, y, state, {
onAction: handleAction,
onDecision: handleDecision,
+ onTool: handleTool,
+ onCameraSwitch: handleCameraSwitch,
onToggleMute: toggleMute,
onAdRevive: handleAd,
onRestart: restart,
@@ -3689,6 +5395,7 @@ function startMiniGame() {
if (!adPauseActive) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
},
});
@@ -3700,11 +5407,20 @@ function startMiniGame() {
for (let i = 0; i < delta; i += 1) {
state = tickState(state, 1);
if (!state.gameOver) {
+ const expiredNightShift = Number(state.tutorialStep || 0) >= 4
+ && Boolean(state.night?.activeEventChainId)
+ && Boolean(state.night?.currentShift?.id);
const expiredKind = state.inspection?.kind;
const expiry = expireInspection(state);
state = expiry.state;
if (expiry.timedOut) {
audio.play(expiry.coached ? 'wrong' : 'result');
+ if (expiredNightShift && !state.gameOver) {
+ state = scheduleFollowingNightShift(state, { correct: false });
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ continue;
+ }
if (expiredKind === 'anomaly' && state.activeAnomaly) {
resolveActiveAnomalyAutomatically('ui.autoResolutionTimeout');
}
@@ -3762,11 +5478,26 @@ function startMiniGame() {
}
if (state.gameOver && !failureRecorded) {
state = state.result === 'success' ? recordSuccessfulShift(state) : recordFailure(state);
+ state = {
+ ...state,
+ night: {
+ ...state.night,
+ overlay: 'debrief',
+ debrief: createNightDebrief(state, __V5_CONTENT__.endings),
+ },
+ };
audio.play('result');
failureRecorded = true;
}
}
+ if (state.gameOver) {
+ audio.stopMusic();
+ } else if (clock.isStarted() && !lifecycleHidden && !adPauseActive && !audio.isMuted()) {
+ const desiredMusic = state.activeAnomaly ? 'pressure' : 'calm';
+ if (audio.getMusicState() !== desiredMusic) audio.setMusicState(desiredMusic);
+ }
+
render(state, getViewState());
nextFrame(api, update);
}
@@ -3776,6 +5507,11 @@ function startMiniGame() {
return { canvas, getState: () => state, restart, start };
}
+__exports_platform_miniGameRuntime_js["createMiniGameRewardedAd"] = createMiniGameRewardedAd;
+__exports_platform_miniGameRuntime_js["startMiniGame"] = startMiniGame;
+}
+var createMiniGameRewardedAd = __exports_platform_miniGameRuntime_js["createMiniGameRewardedAd"];
+var startMiniGame = __exports_platform_miniGameRuntime_js["startMiniGame"];
// ── 平台入口 ──
diff --git a/douyin-minigame/game.json b/douyin-minigame/game.json
index f20c9b9..8a939e6 100644
--- a/douyin-minigame/game.json
+++ b/douyin-minigame/game.json
@@ -5,5 +5,10 @@
"request": 5000,
"connectSocket": 5000
},
- "subPackages": []
+ "subPackages": [
+ {
+ "root": "visual",
+ "name": "v5-visual"
+ }
+ ]
}
\ No newline at end of file
diff --git a/douyin-minigame/project.config.json b/douyin-minigame/project.config.json
index fccf5cc..551eaeb 100644
--- a/douyin-minigame/project.config.json
+++ b/douyin-minigame/project.config.json
@@ -8,7 +8,7 @@
},
"compileType": "game",
"libVersion": "latest",
- "appid": "touristappid",
+ "appid": "ttfd408bfd63251fff02",
"projectname": "异常电梯控制台",
"condition": {}
}
\ No newline at end of file
diff --git a/douyin-minigame/visual/buttons/btn_close_default.png b/douyin-minigame/visual/buttons/btn_close_default.png
index 1993989..82e81ae 100644
Binary files a/douyin-minigame/visual/buttons/btn_close_default.png and b/douyin-minigame/visual/buttons/btn_close_default.png differ
diff --git a/douyin-minigame/visual/buttons/btn_disabled.png b/douyin-minigame/visual/buttons/btn_disabled.png
index c7083e4..fa0469f 100644
Binary files a/douyin-minigame/visual/buttons/btn_disabled.png and b/douyin-minigame/visual/buttons/btn_disabled.png differ
diff --git a/douyin-minigame/visual/buttons/btn_log_secondary.png b/douyin-minigame/visual/buttons/btn_log_secondary.png
index c2103a0..082ae6f 100644
Binary files a/douyin-minigame/visual/buttons/btn_log_secondary.png and b/douyin-minigame/visual/buttons/btn_log_secondary.png differ
diff --git a/douyin-minigame/visual/buttons/btn_more_secondary.png b/douyin-minigame/visual/buttons/btn_more_secondary.png
index b43cf26..082ae6f 100644
Binary files a/douyin-minigame/visual/buttons/btn_more_secondary.png and b/douyin-minigame/visual/buttons/btn_more_secondary.png differ
diff --git a/douyin-minigame/visual/buttons/btn_pressed.png b/douyin-minigame/visual/buttons/btn_pressed.png
index 1cc7113..96e5d3a 100644
Binary files a/douyin-minigame/visual/buttons/btn_pressed.png and b/douyin-minigame/visual/buttons/btn_pressed.png differ
diff --git a/douyin-minigame/visual/buttons/btn_scan_default.png b/douyin-minigame/visual/buttons/btn_scan_default.png
index d65c589..82e81ae 100644
Binary files a/douyin-minigame/visual/buttons/btn_scan_default.png and b/douyin-minigame/visual/buttons/btn_scan_default.png differ
diff --git a/douyin-minigame/visual/buttons/btn_stop_danger.png b/douyin-minigame/visual/buttons/btn_stop_danger.png
index 48b005e..aee23ff 100644
Binary files a/douyin-minigame/visual/buttons/btn_stop_danger.png and b/douyin-minigame/visual/buttons/btn_stop_danger.png differ
diff --git a/douyin-minigame/visual/buttons/btn_up_recommended.png b/douyin-minigame/visual/buttons/btn_up_recommended.png
index d5cf7d7..6c2d43b 100644
Binary files a/douyin-minigame/visual/buttons/btn_up_recommended.png and b/douyin-minigame/visual/buttons/btn_up_recommended.png differ
diff --git a/douyin-minigame/visual/cctv/00_idle_closed_mobile.png b/douyin-minigame/visual/cctv/00_idle_closed_mobile.png
index 7c26666..c6aafd9 100644
Binary files a/douyin-minigame/visual/cctv/00_idle_closed_mobile.png and b/douyin-minigame/visual/cctv/00_idle_closed_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/01_door_open_mobile.png b/douyin-minigame/visual/cctv/01_door_open_mobile.png
index 20879cd..ea1721e 100644
Binary files a/douyin-minigame/visual/cctv/01_door_open_mobile.png and b/douyin-minigame/visual/cctv/01_door_open_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/02_door_opening_mobile.png b/douyin-minigame/visual/cctv/02_door_opening_mobile.png
index e57ea29..e683baa 100644
Binary files a/douyin-minigame/visual/cctv/02_door_opening_mobile.png and b/douyin-minigame/visual/cctv/02_door_opening_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/03_door_closing_mobile.png b/douyin-minigame/visual/cctv/03_door_closing_mobile.png
index 347a7a7..70d7787 100644
Binary files a/douyin-minigame/visual/cctv/03_door_closing_mobile.png and b/douyin-minigame/visual/cctv/03_door_closing_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/04_moving_up_mobile.png b/douyin-minigame/visual/cctv/04_moving_up_mobile.png
index 431d14f..5ce57d1 100644
Binary files a/douyin-minigame/visual/cctv/04_moving_up_mobile.png and b/douyin-minigame/visual/cctv/04_moving_up_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/05_moving_down_mobile.png b/douyin-minigame/visual/cctv/05_moving_down_mobile.png
index a6c65dc..a48904a 100644
Binary files a/douyin-minigame/visual/cctv/05_moving_down_mobile.png and b/douyin-minigame/visual/cctv/05_moving_down_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/06_power_low_mobile.png b/douyin-minigame/visual/cctv/06_power_low_mobile.png
index 471bc7c..395fcdc 100644
Binary files a/douyin-minigame/visual/cctv/06_power_low_mobile.png and b/douyin-minigame/visual/cctv/06_power_low_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/07_power_outage_mobile.png b/douyin-minigame/visual/cctv/07_power_outage_mobile.png
index d3c72e0..63e053d 100644
Binary files a/douyin-minigame/visual/cctv/07_power_outage_mobile.png and b/douyin-minigame/visual/cctv/07_power_outage_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/08_emergency_stop_mobile.png b/douyin-minigame/visual/cctv/08_emergency_stop_mobile.png
index 0319299..ea51248 100644
Binary files a/douyin-minigame/visual/cctv/08_emergency_stop_mobile.png and b/douyin-minigame/visual/cctv/08_emergency_stop_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/09_door_jammed_mobile.png b/douyin-minigame/visual/cctv/09_door_jammed_mobile.png
index b3d36e2..97a4111 100644
Binary files a/douyin-minigame/visual/cctv/09_door_jammed_mobile.png and b/douyin-minigame/visual/cctv/09_door_jammed_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/10_signal_lost_mobile.png b/douyin-minigame/visual/cctv/10_signal_lost_mobile.png
index 770c417..9d84f29 100644
Binary files a/douyin-minigame/visual/cctv/10_signal_lost_mobile.png and b/douyin-minigame/visual/cctv/10_signal_lost_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/11_camera_glitch_mobile.png b/douyin-minigame/visual/cctv/11_camera_glitch_mobile.png
index 5cab460..4dbdb56 100644
Binary files a/douyin-minigame/visual/cctv/11_camera_glitch_mobile.png and b/douyin-minigame/visual/cctv/11_camera_glitch_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/12_scan_active_mobile.png b/douyin-minigame/visual/cctv/12_scan_active_mobile.png
index e5ef20f..549a744 100644
Binary files a/douyin-minigame/visual/cctv/12_scan_active_mobile.png and b/douyin-minigame/visual/cctv/12_scan_active_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/13_entity_near_mobile.png b/douyin-minigame/visual/cctv/13_entity_near_mobile.png
index 3160859..9a37c79 100644
Binary files a/douyin-minigame/visual/cctv/13_entity_near_mobile.png and b/douyin-minigame/visual/cctv/13_entity_near_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/14_shadow_inside_mobile.png b/douyin-minigame/visual/cctv/14_shadow_inside_mobile.png
index 3bc6688..5227a41 100644
Binary files a/douyin-minigame/visual/cctv/14_shadow_inside_mobile.png and b/douyin-minigame/visual/cctv/14_shadow_inside_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/15_anomaly_wandering_mobile.png b/douyin-minigame/visual/cctv/15_anomaly_wandering_mobile.png
index c3a71e6..11e510c 100644
Binary files a/douyin-minigame/visual/cctv/15_anomaly_wandering_mobile.png and b/douyin-minigame/visual/cctv/15_anomaly_wandering_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/16_wrong_floor_mobile.png b/douyin-minigame/visual/cctv/16_wrong_floor_mobile.png
index 9ada129..2cbd358 100644
Binary files a/douyin-minigame/visual/cctv/16_wrong_floor_mobile.png and b/douyin-minigame/visual/cctv/16_wrong_floor_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/17_loop_corridor_mobile.png b/douyin-minigame/visual/cctv/17_loop_corridor_mobile.png
index a8ef9a8..2a6c629 100644
Binary files a/douyin-minigame/visual/cctv/17_loop_corridor_mobile.png and b/douyin-minigame/visual/cctv/17_loop_corridor_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/18_locked_mobile.png b/douyin-minigame/visual/cctv/18_locked_mobile.png
index 2e198ea..0acf7ec 100644
Binary files a/douyin-minigame/visual/cctv/18_locked_mobile.png and b/douyin-minigame/visual/cctv/18_locked_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/19_stabilized_mobile.png b/douyin-minigame/visual/cctv/19_stabilized_mobile.png
index 1838075..4de999f 100644
Binary files a/douyin-minigame/visual/cctv/19_stabilized_mobile.png and b/douyin-minigame/visual/cctv/19_stabilized_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/20_threat_high_mobile.png b/douyin-minigame/visual/cctv/20_threat_high_mobile.png
index 6922832..e11711f 100644
Binary files a/douyin-minigame/visual/cctv/20_threat_high_mobile.png and b/douyin-minigame/visual/cctv/20_threat_high_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/21_maintenance_mode_mobile.png b/douyin-minigame/visual/cctv/21_maintenance_mode_mobile.png
index 25e2eb6..5f57cfb 100644
Binary files a/douyin-minigame/visual/cctv/21_maintenance_mode_mobile.png and b/douyin-minigame/visual/cctv/21_maintenance_mode_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/22_system_reboot_mobile.png b/douyin-minigame/visual/cctv/22_system_reboot_mobile.png
index 95d180f..e0db7c7 100644
Binary files a/douyin-minigame/visual/cctv/22_system_reboot_mobile.png and b/douyin-minigame/visual/cctv/22_system_reboot_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/23_cooldown_safe_mobile.png b/douyin-minigame/visual/cctv/23_cooldown_safe_mobile.png
index 28595ef..f2d4091 100644
Binary files a/douyin-minigame/visual/cctv/23_cooldown_safe_mobile.png and b/douyin-minigame/visual/cctv/23_cooldown_safe_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_00_protocol_start_mobile.png b/douyin-minigame/visual/cctv/v5_00_protocol_start_mobile.png
new file mode 100644
index 0000000..1b3286a
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_00_protocol_start_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_01_quick_mobile.png b/douyin-minigame/visual/cctv/v5_01_quick_mobile.png
new file mode 100644
index 0000000..6e2a3db
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_01_quick_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_02_investigation_mobile.png b/douyin-minigame/visual/cctv/v5_02_investigation_mobile.png
new file mode 100644
index 0000000..94169fc
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_02_investigation_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_03_identity_mobile.png b/douyin-minigame/visual/cctv/v5_03_identity_mobile.png
new file mode 100644
index 0000000..9c6f292
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_03_identity_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_04_classification_mobile.png b/douyin-minigame/visual/cctv/v5_04_classification_mobile.png
new file mode 100644
index 0000000..ff75122
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_04_classification_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_05_high_risk_mobile.png b/douyin-minigame/visual/cctv/v5_05_high_risk_mobile.png
new file mode 100644
index 0000000..5e5f81a
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_05_high_risk_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_06_protocol_query_mobile.png b/douyin-minigame/visual/cctv/v5_06_protocol_query_mobile.png
new file mode 100644
index 0000000..3b82c62
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_06_protocol_query_mobile.png differ
diff --git a/douyin-minigame/visual/cctv/v5_07_debrief_mobile.png b/douyin-minigame/visual/cctv/v5_07_debrief_mobile.png
new file mode 100644
index 0000000..5af6443
Binary files /dev/null and b/douyin-minigame/visual/cctv/v5_07_debrief_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/README.md b/games/find-anomaly/elevator-console/README.md
index f3be417..d8bc83d 100644
--- a/games/find-anomaly/elevator-console/README.md
+++ b/games/find-anomaly/elevator-console/README.md
@@ -32,7 +32,7 @@ MINIGAME / games / find-anomaly / elevator-console
| 资产包 | 路径 | 用途 |
|---|---|---|
| UI 组件包 | `assets/abnormal_elevator_ui_kit/` | 移动端控制台组件、按钮状态、组件 tokens、图标预览 |
-| 视觉状态包 | `assets/abnormal_elevator_visual_assets/` | CCTV 状态图、移动端裁切图、按钮贴图、overlays、spritesheets、manifest |
+| 视觉状态包 | `assets/abnormal_elevator_visual_assets/` | CCTV 状态图、移动端裁切图、按钮贴图、overlays、manifest |
当前 H5 / Android WebView 运行时代码仍引用仓库根部 `assets/generated/` 中的既有背景资源;新资产包已归档到游戏目录,后续接线时再按 manifest 替换运行时引用。
@@ -61,7 +61,6 @@ MINIGAME / games / find-anomaly / elevator-console
- `assets/abnormal_elevator_visual_assets/mobile_cctv_states/`:24 张移动端裁切图已接入移动端 CSS 覆盖层。
- `assets/abnormal_elevator_visual_assets/button_sprites/`:8 张按钮贴图已作为控制键外观层接入,按钮文字仍由 DOM 渲染。
- `assets/abnormal_elevator_visual_assets/overlays/`:6 张 overlay 已接入 CCTV 框、扫描线、故障块、红警框、暗角和扫描光束。
-- `assets/abnormal_elevator_visual_assets/spritesheets/`:仅作总览和人工检查,不直接进入运行时。
- `assets/abnormal_elevator_ui_kit/`:作为组件参考和 tokens 归档,不直接覆盖当前可玩 UI。
Android WebView 通过 `scripts/prepare-android-webview.mjs` 复制上述视觉资源,并把 CSS 中的游戏目录路径重写为包内 `assets/abnormal_elevator_visual_assets/`。
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_ui_kit/abnormal-elevator-icons.svg b/games/find-anomaly/elevator-console/assets/abnormal_elevator_ui_kit/abnormal-elevator-icons.svg
deleted file mode 100644
index f701cf9..0000000
--- a/games/find-anomaly/elevator-console/assets/abnormal_elevator_ui_kit/abnormal-elevator-icons.svg
+++ /dev/null
@@ -1,60 +0,0 @@
-
\ No newline at end of file
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/README.md b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/README.md
index 9b0af1a..e7f659f 100644
--- a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/README.md
+++ b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/README.md
@@ -1,16 +1,14 @@
# 异常电梯控制台 · 视觉资产包
-这是给小游戏实际开发用的视觉资产,不是海报效果图。包含完整电梯状态画面、按钮贴图、图标、遮罩和资源清单。
+这是给小游戏实际开发用的运行时视觉资产,包含完整电梯状态画面、按钮贴图、遮罩和资源清单。
## 目录
- `cctv_states/`:1024×576 全部电梯 CCTV 状态图
- `mobile_cctv_states/`:720×420 手机 UI 顶部监控区裁切版
- `button_sprites/`:工业控制台触控键 PNG
-- `icons/`:SVG 线性控制图标
- `overlays/`:CCTV 扫描线、故障、红警、暗角、扫描光束遮罩
-- `spritesheets/`:状态总览图、按钮图集
- `docs/ELEVATOR_STATE_IMAGE_LIST.md`:全部电梯状态图片清单
-- `manifest.json`:可交给前端或游戏引擎读取的资源清单
+- `manifest.json`:运行时资源清单
## 已覆盖电梯状态
正常待机、门开、开门中、关门中、上行、下行、电力不足、断电、急停、门卡滞、信号丢失、摄像头故障、扫描中、实体接近、轿厢阴影、异常徘徊、楼层错位、走廊循环、系统锁定、稳定恢复、威胁高、维护模式、系统重启、冷却安全期。
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_close_default.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_close_default.png
index 1993989..82e81ae 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_close_default.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_close_default.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_disabled.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_disabled.png
index c7083e4..fa0469f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_disabled.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_disabled.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_log_secondary.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_log_secondary.png
index c2103a0..082ae6f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_log_secondary.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_log_secondary.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_more_secondary.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_more_secondary.png
index b43cf26..082ae6f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_more_secondary.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_more_secondary.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_pressed.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_pressed.png
index 1cc7113..96e5d3a 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_pressed.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_pressed.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_scan_default.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_scan_default.png
index d65c589..82e81ae 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_scan_default.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_scan_default.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_stop_danger.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_stop_danger.png
index 48b005e..aee23ff 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_stop_danger.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_stop_danger.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_up_recommended.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_up_recommended.png
index d5cf7d7..6c2d43b 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_up_recommended.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/button_sprites/btn_up_recommended.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png
index 9c4c32a..c74133f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/00_idle_closed.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/01_door_open.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/01_door_open.png
index f24f749..57b2bdc 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/01_door_open.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/01_door_open.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/02_door_opening.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/02_door_opening.png
index c55adfd..565f5f3 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/02_door_opening.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/02_door_opening.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/03_door_closing.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/03_door_closing.png
index dff8d2f..653d005 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/03_door_closing.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/03_door_closing.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/04_moving_up.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/04_moving_up.png
index 234b6c3..0f3cf1b 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/04_moving_up.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/04_moving_up.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/05_moving_down.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/05_moving_down.png
index 4a3a378..0997ee2 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/05_moving_down.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/05_moving_down.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/06_power_low.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/06_power_low.png
index 589da12..d57f3e8 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/06_power_low.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/06_power_low.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/07_power_outage.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/07_power_outage.png
index 3eadf3b..c8051a9 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/07_power_outage.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/07_power_outage.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/08_emergency_stop.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/08_emergency_stop.png
index 6ec1c13..164972d 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/08_emergency_stop.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/08_emergency_stop.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/09_door_jammed.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/09_door_jammed.png
index 0a49cbd..138c639 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/09_door_jammed.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/09_door_jammed.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/10_signal_lost.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/10_signal_lost.png
index 3359db7..61edfad 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/10_signal_lost.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/10_signal_lost.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png
index 086c9c2..6e5e9a5 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/11_camera_glitch.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/12_scan_active.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/12_scan_active.png
index 9f83efd..08f99a2 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/12_scan_active.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/12_scan_active.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/13_entity_near.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/13_entity_near.png
index 1868c47..1d77e99 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/13_entity_near.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/13_entity_near.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/14_shadow_inside.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/14_shadow_inside.png
index 5b5b134..23268ac 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/14_shadow_inside.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/14_shadow_inside.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/15_anomaly_wandering.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/15_anomaly_wandering.png
index e122ee1..49ecb30 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/15_anomaly_wandering.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/15_anomaly_wandering.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/16_wrong_floor.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/16_wrong_floor.png
index b5f5fb0..aae0d1e 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/16_wrong_floor.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/16_wrong_floor.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/17_loop_corridor.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/17_loop_corridor.png
index 4a65370..118ce6f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/17_loop_corridor.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/17_loop_corridor.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/18_locked.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/18_locked.png
index 7336307..9c44ed6 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/18_locked.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/18_locked.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/19_stabilized.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/19_stabilized.png
index e2dec0c..fa2f881 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/19_stabilized.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/19_stabilized.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png
index 747ea98..05bdf02 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/20_threat_high.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/21_maintenance_mode.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/21_maintenance_mode.png
index beeaf7e..b5461e5 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/21_maintenance_mode.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/21_maintenance_mode.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/22_system_reboot.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/22_system_reboot.png
index 94e5421..6a8aaf0 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/22_system_reboot.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/22_system_reboot.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/23_cooldown_safe.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/23_cooldown_safe.png
index 16c4d62..0b88680 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/23_cooldown_safe.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/cctv_states/23_cooldown_safe.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/icons/control_icons.svg b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/icons/control_icons.svg
deleted file mode 100644
index b4739bc..0000000
--- a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/icons/control_icons.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/manifest.json b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/manifest.json
index 725d57b..bbe1c2f 100644
--- a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/manifest.json
+++ b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/manifest.json
@@ -16,9 +16,7 @@
"cctv_states": "1024×576 CCTV 电梯状态图,适合监控画面主体。",
"mobile_cctv_states": "720×420 手机端裁切版,适合直接放进顶部 CCTV 区。",
"button_sprites": "工业物理触控键 PNG 贴图。",
- "icons": "控制按钮 SVG 线性图标。",
"overlays": "透明 PNG 遮罩:扫描线、CCTV框、红警、扫描光束、故障块、暗角。",
- "spritesheets": "总览图和按钮图集。",
"docs": "资源说明、状态表、导入建议。"
},
"cctv_states": [
@@ -274,11 +272,5 @@
"id": "overlay_vignette",
"png": "overlays/overlay_vignette.png"
}
- ],
- "icons": [
- {
- "id": "control_icons",
- "svg": "icons/control_icons.svg"
- }
]
}
\ No newline at end of file
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/00_idle_closed_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/00_idle_closed_mobile.png
index 7c26666..c6aafd9 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/00_idle_closed_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/00_idle_closed_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/01_door_open_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/01_door_open_mobile.png
index 20879cd..ea1721e 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/01_door_open_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/01_door_open_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/02_door_opening_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/02_door_opening_mobile.png
index e57ea29..e683baa 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/02_door_opening_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/02_door_opening_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/03_door_closing_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/03_door_closing_mobile.png
index 347a7a7..70d7787 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/03_door_closing_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/03_door_closing_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/04_moving_up_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/04_moving_up_mobile.png
index 431d14f..5ce57d1 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/04_moving_up_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/04_moving_up_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/05_moving_down_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/05_moving_down_mobile.png
index a6c65dc..a48904a 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/05_moving_down_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/05_moving_down_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/06_power_low_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/06_power_low_mobile.png
index 471bc7c..395fcdc 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/06_power_low_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/06_power_low_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/07_power_outage_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/07_power_outage_mobile.png
index d3c72e0..63e053d 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/07_power_outage_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/07_power_outage_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/08_emergency_stop_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/08_emergency_stop_mobile.png
index 0319299..ea51248 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/08_emergency_stop_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/08_emergency_stop_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/09_door_jammed_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/09_door_jammed_mobile.png
index b3d36e2..97a4111 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/09_door_jammed_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/09_door_jammed_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/10_signal_lost_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/10_signal_lost_mobile.png
index 770c417..9d84f29 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/10_signal_lost_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/10_signal_lost_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/11_camera_glitch_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/11_camera_glitch_mobile.png
index 5cab460..4dbdb56 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/11_camera_glitch_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/11_camera_glitch_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/12_scan_active_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/12_scan_active_mobile.png
index e5ef20f..549a744 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/12_scan_active_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/12_scan_active_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/13_entity_near_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/13_entity_near_mobile.png
index 3160859..9a37c79 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/13_entity_near_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/13_entity_near_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/14_shadow_inside_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/14_shadow_inside_mobile.png
index 3bc6688..5227a41 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/14_shadow_inside_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/14_shadow_inside_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/15_anomaly_wandering_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/15_anomaly_wandering_mobile.png
index c3a71e6..11e510c 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/15_anomaly_wandering_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/15_anomaly_wandering_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/16_wrong_floor_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/16_wrong_floor_mobile.png
index 9ada129..2cbd358 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/16_wrong_floor_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/16_wrong_floor_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/17_loop_corridor_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/17_loop_corridor_mobile.png
index a8ef9a8..2a6c629 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/17_loop_corridor_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/17_loop_corridor_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/18_locked_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/18_locked_mobile.png
index 2e198ea..0acf7ec 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/18_locked_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/18_locked_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/19_stabilized_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/19_stabilized_mobile.png
index 1838075..4de999f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/19_stabilized_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/19_stabilized_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/20_threat_high_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/20_threat_high_mobile.png
index 6922832..e11711f 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/20_threat_high_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/20_threat_high_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/21_maintenance_mode_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/21_maintenance_mode_mobile.png
index 25e2eb6..5f57cfb 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/21_maintenance_mode_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/21_maintenance_mode_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/22_system_reboot_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/22_system_reboot_mobile.png
index 95d180f..e0db7c7 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/22_system_reboot_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/22_system_reboot_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/23_cooldown_safe_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/23_cooldown_safe_mobile.png
index 28595ef..f2d4091 100644
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/23_cooldown_safe_mobile.png and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/23_cooldown_safe_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_00_protocol_start_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_00_protocol_start_mobile.png
new file mode 100644
index 0000000..1b3286a
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_00_protocol_start_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_01_quick_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_01_quick_mobile.png
new file mode 100644
index 0000000..6e2a3db
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_01_quick_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_02_investigation_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_02_investigation_mobile.png
new file mode 100644
index 0000000..94169fc
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_02_investigation_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_03_identity_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_03_identity_mobile.png
new file mode 100644
index 0000000..9c6f292
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_03_identity_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_04_classification_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_04_classification_mobile.png
new file mode 100644
index 0000000..ff75122
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_04_classification_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_05_high_risk_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_05_high_risk_mobile.png
new file mode 100644
index 0000000..5e5f81a
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_05_high_risk_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_06_protocol_query_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_06_protocol_query_mobile.png
new file mode 100644
index 0000000..3b82c62
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_06_protocol_query_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_07_debrief_mobile.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_07_debrief_mobile.png
new file mode 100644
index 0000000..5af6443
Binary files /dev/null and b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/mobile_cctv_states/v5_07_debrief_mobile.png differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/button_spritesheet.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/button_spritesheet.png
deleted file mode 100644
index 0ff1688..0000000
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/button_spritesheet.png and /dev/null differ
diff --git a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/cctv_states_contact_sheet.png b/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/cctv_states_contact_sheet.png
deleted file mode 100644
index 4ad5eb1..0000000
Binary files a/games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/spritesheets/cctv_states_contact_sheet.png and /dev/null differ
diff --git a/package.json b/package.json
index 2dd2a30..4bc71cc 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"verify": "node scripts/verify-all.cjs",
"verify:summary": "node scripts/verify-all.cjs --summary",
"skins:check": "node scripts/validate-skins.mjs",
+ "content:v5:check": "node scripts/validate-v5-content.mjs",
"skin:new": "node scripts/create-skin-from-template.mjs"
}
}
diff --git a/platform/canvasAssets.js b/platform/canvasAssets.js
index 25f012a..0b6609c 100644
--- a/platform/canvasAssets.js
+++ b/platform/canvasAssets.js
@@ -7,6 +7,22 @@ const CCTV_STATE_IDS = Object.freeze([
'20_threat_high', '21_maintenance_mode', '22_system_reboot', '23_cooldown_safe',
]);
+const CCTV_STATE_ALIASES = Object.freeze({
+ // V5 内容描述“重复主体”,现有移动素材以影子主体表现同一类空间入侵;保留内容 ID,显式复用已发布图。
+ '14_duplicate_subject': '14_shadow_inside',
+});
+
+const V5_CCTV_ASSETS = Object.freeze({
+ protocolStart: 'visual/cctv/v5_00_protocol_start_mobile.png',
+ quick: 'visual/cctv/v5_01_quick_mobile.png',
+ investigation: 'visual/cctv/v5_02_investigation_mobile.png',
+ identity: 'visual/cctv/v5_03_identity_mobile.png',
+ classification: 'visual/cctv/v5_04_classification_mobile.png',
+ highRisk: 'visual/cctv/v5_05_high_risk_mobile.png',
+ protocolQuery: 'visual/cctv/v5_06_protocol_query_mobile.png',
+ debrief: 'visual/cctv/v5_07_debrief_mobile.png',
+});
+
const BUTTON_ASSETS = Object.freeze({
default: 'visual/buttons/btn_close_default.png',
recommended: 'visual/buttons/btn_up_recommended.png',
@@ -29,7 +45,11 @@ const OVERLAY_ASSETS = Object.freeze({
export function getCanvasVisualAssetManifest() {
return {
- cctv: Object.fromEntries(CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`])),
+ cctv: Object.fromEntries([
+ ...CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`]),
+ ...Object.entries(CCTV_STATE_ALIASES).map(([id, target]) => [id, `visual/cctv/${target}_mobile.png`]),
+ ]),
+ v5Cctv: { ...V5_CCTV_ASSETS },
buttons: { ...BUTTON_ASSETS },
overlays: { ...OVERLAY_ASSETS },
};
@@ -60,6 +80,7 @@ export function createCanvasAssetStore(imageFactory) {
function preload() {
for (const path of Object.values(manifest.cctv)) load(path);
+ for (const path of Object.values(manifest.v5Cctv)) load(path);
for (const path of Object.values(manifest.buttons)) load(path);
for (const path of Object.values(manifest.overlays)) load(path);
}
@@ -73,6 +94,7 @@ export function createCanvasAssetStore(imageFactory) {
manifest,
preload,
getCctv: stateId => get(manifest.cctv[stateId] || manifest.cctv['00_idle_closed']),
+ getV5Cctv: screenId => get(manifest.v5Cctv[screenId] || manifest.v5Cctv.quick),
getButton: kind => get(manifest.buttons[kind] || manifest.buttons.default),
getOverlay: kind => get(manifest.overlays[kind]),
getStatus: () => ({
diff --git a/platform/canvasLabels.js b/platform/canvasLabels.js
index a404ad8..7ebb76c 100644
--- a/platform/canvasLabels.js
+++ b/platform/canvasLabels.js
@@ -12,7 +12,7 @@ export function getCanvasLabels() {
actionPanel: canvas.actionPanel || '操作面板',
logPanel: canvas.logPanel || '系统日志',
failureTitle: canvas.failureTitle || '系统崩溃',
- failureEyebrow: canvas.failureEyebrow || 'SYSTEM FAILURE',
+ failureEyebrow: canvas.failureEyebrow || '系统故障',
revive: t('ui.viewAd'),
restart: t('ui.restart'),
revealTruth: t('ui.revealTruth'),
diff --git a/platform/canvasRenderer.js b/platform/canvasRenderer.js
index 22a4ff3..dc6d5e1 100644
--- a/platform/canvasRenderer.js
+++ b/platform/canvasRenderer.js
@@ -54,24 +54,38 @@ export function getCanvasViewportMetrics(systemInfo = {}) {
}
export function getCanvasLayout(height = 1334, safeTop = 0) {
- // V4:一块大监控、三项读数、一个双选任务。禁止把桌面后台缩进手机。
+ // V5:协议与 CAM 使用原生 Canvas 行;大 CCTV 仍是最大单一表面。
const topbar = { x: 14, y: 12 + safeTop, w: 722, h: 76 };
- const rule = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
- const monitorH = Math.max(520, Math.min(880, height - safeTop - 644));
- const monitor = { x: 14, y: 170 + safeTop, w: 722, h: monitorH };
+ const protocolBar = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
+ const cameraTabs = {
+ x: 14, y: 170 + safeTop, w: 722, h: 54, gap: 8,
+ hitY: 146 + safeTop, hitH: 94,
+ };
+ // Match the two official V5 portrait frames: 360×640 uses a compact 230px CCTV,
+ // while 393×852 spends the extra vertical room on a 360px CCTV. Interpolation
+ // keeps intermediate phones fluid without creating a dead area below the monitor.
+ const monitorH = Math.max(479, Math.min(687, 479 + (height - 1334) * (208 / 291)));
+ const monitor = { x: 14, y: 232 + safeTop, w: 722, h: monitorH };
const readings = { x: 14, y: monitor.y + monitor.h + 12, w: 722, h: 108 };
+ const tools = {
+ x: 14, y: readings.y + readings.h + 12, w: 722, h: 76, gap: 10,
+ hitY: readings.y + readings.h + 2, hitH: 100,
+ };
const actions = {
- x: 14, y: readings.y + readings.h + 12, w: 722, h: 220,
- columns: 2, gap: 14, buttonH: 164,
+ x: 14, y: tools.y + tools.h + 12, w: 722, h: 146,
+ columns: 2, gap: 14, buttonH: 104,
};
actions.startY = actions.y + 42;
actions.buttonW = (actions.w - 32 - actions.gap) / 2;
const feedbackY = actions.y + actions.h + 12;
return {
topbar,
- rule,
+ rule: protocolBar,
+ protocolBar,
+ cameraTabs,
monitor,
readings,
+ tools,
actions,
feedback: { x: 14, y: feedbackY, w: 722, h: Math.max(90, height - feedbackY - 18) },
};
@@ -225,15 +239,64 @@ function getRuleCopy(state) {
return t('ui.coreRule');
}
-function drawRuleStrip(state) {
- const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).rule;
+export function getCanvasProtocolItems(state) {
+ return (state?.night?.activeProtocols || []).slice(0, 3).map(protocol => ({
+ id: protocol.id,
+ category: protocol.category || 'protocol',
+ text: protocol.text || protocol.id,
+ }));
+}
+
+export function getCanvasProtocolSummary(protocols = []) {
+ return protocols.map((protocol, index) => {
+ const text = String(protocol?.text || protocol?.id || '');
+ const compact = text.length > 14 ? `${text.slice(0, 14)}…` : text;
+ return `${index + 1}.${compact}`;
+ }).join(' ');
+}
+
+function drawProtocolBar(state) {
+ const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).protocolBar;
drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.40)');
- const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
- ctx.fillStyle = guided ? COLORS.amber : COLORS.green;
+ const protocols = getCanvasProtocolItems(state);
+ ctx.fillStyle = protocols.length ? COLORS.amber : COLORS.green;
ctx.fillRect(x + 6, y + 6, 7, h - 12);
ctx.fillStyle = COLORS.text;
- ctx.font = '26px "Microsoft YaHei", sans-serif';
- ctx.fillText(getRuleCopy(state), x + 30, y + 43, w - 142);
+ ctx.font = 'bold 20px "Microsoft YaHei", sans-serif';
+ ctx.fillText('夜班协议', x + 28, y + 26);
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
+ const summary = guided || !protocols.length
+ ? getRuleCopy(state)
+ : getCanvasProtocolSummary(protocols);
+ ctx.fillText(summary, x + 28, y + 52, w - 52);
+}
+
+export function getCanvasCameraTabs(state) {
+ const cameras = Object.keys(state?.night?.currentShift?.evidence?.cameras || {});
+ const activeCamera = state?.investigation?.activeCamera || 'cam01';
+ return ['cam01', 'cam03', 'cam07']
+ .filter(id => cameras.includes(id))
+ .map(id => ({ id, label: id.replace('cam', 'CAM-'), active: id === activeCamera }));
+}
+
+function drawCameraTabs(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const tabs = getCanvasCameraTabs(state);
+ if (!tabs.length) return;
+ const tabW = (layout.w - layout.gap * (tabs.length - 1)) / tabs.length;
+ tabs.forEach((tab, index) => {
+ const x = layout.x + index * (tabW + layout.gap);
+ roundRect(x, layout.y, tabW, layout.h, 2,
+ tab.active ? '#17352a' : '#101314',
+ tab.active ? 'rgba(121,214,163,0.78)' : 'rgba(195,200,190,0.24)');
+ ctx.fillStyle = tab.active ? COLORS.green : COLORS.muted;
+ ctx.font = 'bold 22px Consolas, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(tab.label, x + tabW / 2, layout.y + 35);
+ drawPressShade(x, layout.y, tabW, layout.h, getPressDepth(tab.id));
+ });
+ ctx.textAlign = 'left';
}
export function getCanvasReadings(state, motion = null) {
@@ -294,7 +357,14 @@ function drawFeedback(state) {
ctx.textAlign = 'right';
ctx.fillText(pending ? '等待判断' : `安全 ${Math.round(state.stability || 0)}%`, x + w - 24, y + 42);
ctx.textAlign = 'left';
- const barY = y + Math.min(h - 22, 62);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const power = Math.max(0, Math.min(100, Math.round(Number(state.power) || 0)));
+ const contamination = Math.max(0, Math.min(100, Math.round(Number(state.contamination?.value) || 0)));
+ ctx.fillText(`电力 ${power}%`, x + 24, y + 70);
+ ctx.fillStyle = contamination >= 51 ? COLORS.red : contamination >= 26 ? COLORS.amber : COLORS.cyan;
+ ctx.fillText(`污染 ${contamination}%`, x + 168, y + 70);
+ const barY = y + Math.min(h - 22, 78);
roundRect(x + 24, barY, w - 48, 12, 2, 'rgba(255,255,255,0.08)');
if (!pending) {
roundRect(x + 24, barY, Math.max(0, (w - 48) * ((state.stability || 0) / 100)), 12, 2,
@@ -431,12 +501,22 @@ export function getCanvasCctvTreatment(cctvState = '00_idle_closed') {
const entity = ['13_entity_near', '14_shadow_inside', '15_anomaly_wandering'].includes(cctvState);
const threat = ['08_emergency_stop', '09_door_jammed', '16_wrong_floor', '20_threat_high'].includes(cctvState);
const darkness = cctvState === '07_power_outage' ? 0.62 : cctvState === '10_signal_lost' ? 0.38 : 0;
+ const calm = cctvState === '19_stabilized' || cctvState === '23_cooldown_safe';
const tint = threat
? 'rgba(255,77,109,0.16)'
- : cctvState === '19_stabilized' || cctvState === '23_cooldown_safe'
+ : calm
? 'rgba(97,255,190,0.12)'
: 'rgba(97,255,190,0.05)';
- return { tint, darkness, entity, glitch, threat };
+ const border = threat
+ ? 'rgba(255,77,109,0.85)'
+ : glitch
+ ? 'rgba(225,168,75,0.62)'
+ : entity
+ ? 'rgba(178,132,255,0.62)'
+ : calm
+ ? 'rgba(97,255,190,0.52)'
+ : 'rgba(121,214,163,0.34)';
+ return { tint, darkness, entity, glitch, threat, border };
}
function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight = 420) {
@@ -455,34 +535,109 @@ function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight =
ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
}
-function drawCctvImage(image, x, y, w, h) {
- const sourceW = Number(image.width || image.naturalWidth) || 720;
- const sourceH = Number(image.height || image.naturalHeight) || 420;
- // 生产状态图顶部/底部烘焙了英文诊断和固定HUD;先裁掉答案区,再按主画面 cover。
- const cropTop = Math.min(58, sourceH * 0.14);
- const cropBottom = Math.min(30, sourceH * 0.08);
- const usableH = sourceH - cropTop - cropBottom;
- const sourceRatio = sourceW / usableH;
- const targetRatio = w / h;
- let sx = 0, sy = cropTop, sw = sourceW, sh = usableH;
- if (sourceRatio > targetRatio) {
- sw = usableH * targetRatio;
- sx = (sourceW - sw) / 2;
- } else {
- sh = sourceW / targetRatio;
- sy = cropTop + (usableH - sh) / 2;
+function drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime = 0) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+ ctx.clip();
+
+ // 真实监控感:扫描线 + 镜头暗角 + 轻微色偏。三层均只作用于 CCTV,不污染按钮和协议。
+ const scanlines = assetStore?.getOverlay('scanlines');
+ const vignette = assetStore?.getOverlay('vignette');
+ const frame = assetStore?.getOverlay('frame');
+ if (scanlines) {
+ ctx.globalAlpha = treatment.threat ? 0.38 : 0.24;
+ ctx.drawImage(scanlines, x, y, w, h);
}
- ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
+ if (vignette) {
+ ctx.globalAlpha = treatment.threat ? 0.82 : 0.62;
+ ctx.drawImage(vignette, x, y, w, h);
+ }
+ ctx.globalAlpha = 1;
+
+ if (treatment.tint) {
+ ctx.fillStyle = treatment.tint;
+ ctx.fillRect(x, y, w, h);
+ }
+
+ // 慢速 CRT 扫描带:比静态噪点更容易让玩家感到“摄像头正在工作”。
+ const phase = ((frameTime / 1800) % 1 + 1) % 1;
+ const beamY = y + phase * h;
+ const beam = ctx.createLinearGradient(x, beamY - 30, x, beamY + 30);
+ beam.addColorStop(0, 'rgba(97,255,190,0)');
+ beam.addColorStop(0.5, treatment.threat ? 'rgba(255,77,109,0.30)' : 'rgba(97,255,190,0.22)');
+ beam.addColorStop(1, 'rgba(97,255,190,0)');
+ ctx.fillStyle = beam;
+ ctx.fillRect(x, beamY - 30, w, 60);
+
+ // 录制指示器与镜头角标是运行时 HUD,不泄露答案,只建立“夜班监控”语境。
+ const pulse = 0.72 + Math.sin(frameTime / 170) * 0.22;
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = treatment.threat ? COLORS.red : '#ff5d67';
+ ctx.beginPath();
+ ctx.arc(x + 20, y + 22, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#e4e8df';
+ ctx.font = 'bold 16px Consolas, monospace';
+ ctx.fillText('REC', x + 32, y + 28);
+ ctx.fillStyle = treatment.threat ? '#ff9a9f' : '#b4c4bb';
+ ctx.font = '14px Consolas, monospace';
+ const activeCamera = String(state?.investigation?.activeCamera || 'cam01').toUpperCase().replace('CAM', 'CAM-');
+ ctx.fillText(`${activeCamera} // NIGHT WATCH`, x + 20, y + h - 18);
+
+ // 角框比一整圈发光边框更克制,但会让 CCTV 从“普通图片”变成监控窗口。
+ if (frame) {
+ ctx.globalAlpha = 0.72;
+ ctx.drawImage(frame, x, y, w, h);
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = treatment.border || 'rgba(121,214,163,0.44)';
+ ctx.lineWidth = treatment.threat ? 3 + Math.max(0, Math.sin(frameTime / 130)) : 2;
+ ctx.strokeRect(x + 2, y + 2, w - 4, h - 4);
+
+ if (treatment.threat) {
+ ctx.globalAlpha = 0.72 + Math.sin(frameTime / 110) * 0.18;
+ ctx.strokeStyle = COLORS.red;
+ ctx.lineWidth = 4;
+ ctx.strokeRect(x + 8, y + 8, w - 16, h - 16);
+ ctx.globalAlpha = 1;
+ }
+ ctx.restore();
+}
+
+function drawCctvImage(image, x, y, w, h) {
+ // CCTV 窗口保持主布局尺寸;素材 cover 铺满窗口:无拉伸变形、无黑边、不叠第二层背景。
+ // 高竖屏窗口下中央裁掉两侧边缘,轿厢主体始终居中完整。
+ drawImageCover(image, x, y, w, h);
+}
+
+// V5 阶段场景映射:夜班各回合使用交接包对应场景,运动/异常瞬时态仍回退 24 状态机图。
+export function getV5CctvScreenId(state) {
+ if (state?.night?.overlay === 'protocolQuery') return 'protocolQuery';
+ const roundType = state?.night?.roundType;
+ if (!state?.night?.currentShift) return null;
+ return {
+ quick: 'quick',
+ investigation: 'investigation',
+ identity: 'identity',
+ classification: 'classification',
+ highRisk: 'highRisk',
+ }[roundType] || null;
}
function drawCctvScene(state, x, y, w, h, motion = null) {
if (h <= 20) return;
const baseVisual = deriveVisualState(state);
const frameTime = Number(motion?.frameTime ?? Date.now());
- const cctvState = motion?.cctvState || baseVisual.cctvState;
+ const cctvState = motion?.cctvState
+ || state?.night?.currentShift?.visualState
+ || baseVisual.cctvState;
const visual = { ...baseVisual, cctvState, glitch: baseVisual.glitch || Number(motion?.glitchAlpha || 0) > 0 };
const treatment = getCanvasCctvTreatment(cctvState);
- const sceneImage = assetStore?.getCctv(cctvState);
+ const v5ScreenId = motion?.active ? null : getV5CctvScreenId(state);
+ const sceneImage = (v5ScreenId ? assetStore?.getV5Cctv(v5ScreenId) : null)
+ || assetStore?.getCctv(cctvState);
if (sceneImage) {
ctx.save();
@@ -507,18 +662,32 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
drawCctvImage(sceneImage, drawX, drawY, drawW, drawH);
ctx.globalAlpha = 1;
+ drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime);
+
// 状态图已内置基础监控纹理,只叠加真正随时间变化的警报与干扰。
const pendingDecision = state.inspection?.status === 'pending';
const alert = treatment.threat && !pendingDecision ? assetStore.getOverlay('redAlert') : null;
const glitchOverlay = treatment.glitch ? assetStore.getOverlay('glitch') : null;
- const sweep = state.inspection?.status === 'pending' ? assetStore.getOverlay('sweep') : null;
- for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36], [sweep, 0.28]]) {
+ for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36]]) {
if (!image) continue;
ctx.globalAlpha = alpha;
ctx.drawImage(image, x, y, w, h);
}
ctx.globalAlpha = 1;
+ // 待判定扫描光束随时间自上而下扫过,给出“系统正在核对”的活体感。
+ const sweep = pendingDecision ? assetStore.getOverlay('sweep') : null;
+ if (sweep) {
+ const sweepH = Math.max(96, Math.floor(h * 0.38));
+ const sweepPhase = (frameTime / 2100) % 1.45;
+ if (sweepPhase <= 1) {
+ const sweepY = y - sweepH + sweepPhase * (h + sweepH * 2);
+ ctx.globalAlpha = 0.34;
+ ctx.drawImage(sweep, x, sweepY, w, sweepH);
+ ctx.globalAlpha = 1;
+ }
+ }
+
const glitchAlpha = Math.max(0, Math.min(1, Number(motion?.glitchAlpha || 0)));
if (glitchAlpha > 0) {
ctx.globalAlpha = glitchAlpha;
@@ -546,23 +715,8 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
ctx.fillStyle = scanGradient;
ctx.fillRect(x, scanY - 24, w, 48);
- // 实体式顶部遮光罩:覆盖素材中烘焙的 07 / STABILIZED / 英文诊断,而不是再贴一块中央黑卡。
- const hudShade = ctx.createLinearGradient(0, y, 0, y + 104);
- hudShade.addColorStop(0, '#020707');
- hudShade.addColorStop(0.82, '#020707');
- hudShade.addColorStop(1, 'rgba(2,7,7,0)');
- ctx.fillStyle = hudShade;
- ctx.fillRect(x, y, w, 112);
- ctx.strokeStyle = 'rgba(121,214,163,0.22)';
- ctx.beginPath();
- ctx.moveTo(x, y + 96);
- ctx.lineTo(x + w, y + 96);
- ctx.stroke();
-
- // 状态图含固定英文诊断与固定楼层;源图已裁掉烘焙答案区,这里只叠加中文运行时状态。
+ // 替换图无烘焙 HUD;只绘制运行时楼层和状态标签,不覆盖电梯主体。
const inspectionPending = state.inspection?.status === 'pending';
- const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
- const floorDiscrepancy = ['phantom_floor', 'floor_jump', 'negative_floor'].includes(activeId);
const neutralBorder = inspectionPending ? 'rgba(195,200,190,0.34)' : treatment.border;
ctx.strokeStyle = neutralBorder;
ctx.globalAlpha = 0.72;
@@ -762,12 +916,65 @@ export function getCanvasActionButtons(state) {
return operations;
}
+const TOOL_LABELS = {
+ thermal: '热源扫描',
+ replay: '三秒回放',
+ protocol: '夜班协议',
+};
+
+export function getCanvasToolButtons(state) {
+ const investigation = state?.investigation || {};
+ return ['thermal', 'replay', 'protocol'].map(id => {
+ const tool = investigation.tools?.[id] || {};
+ const remaining = tool.remaining;
+ const unlimited = !Number.isFinite(remaining);
+ const disabled = !unlimited && (remaining <= 0 || (investigation.power ?? 0) < (tool.powerCost || 0));
+ return {
+ id,
+ label: TOOL_LABELS[id],
+ meta: unlimited ? '不限次' : `${remaining || 0}次 · ${tool.powerCost || 0}电`,
+ disabled,
+ };
+ });
+}
+
+const ROUND_ACTIONS = {
+ quick: [
+ { id: 'release', label: '放行', sublabel: '画面数据一致', decision: 'normal' },
+ { id: 'lockdown', label: '封锁', sublabel: '发现任意矛盾', decision: 'anomaly' },
+ ],
+ investigation: [
+ { id: 'markSuspicion', label: '标记疑点', sublabel: '保留当前证据' },
+ { id: 'enterClassification', label: '进入分类', sublabel: '提交异常类型' },
+ ],
+ identity: [
+ { id: 'identityRelease', label: '放行', sublabel: '身份一致' },
+ { id: 'identityReject', label: '拒绝', sublabel: '身份冲突' },
+ { id: 'identityVerify', label: '核验', sublabel: '查看胸牌与权限' },
+ ],
+ classification: [
+ { id: 'classify:person', label: '人物', sublabel: '身份/外观' },
+ { id: 'classify:quantity', label: '数量', sublabel: '人数/载重' },
+ { id: 'classify:space', label: '空间', sublabel: '楼层/位置' },
+ { id: 'classify:time', label: '时间', sublabel: '时序/回放' },
+ { id: 'classify:device', label: '设备', sublabel: '信号/读数' },
+ { id: 'classify:dynamic', label: '动态', sublabel: '移动/变化' },
+ ],
+ highRisk: [
+ { id: 'highRisk:emergencyStop', label: '急停', sublabel: '消耗 15 电' },
+ { id: 'highRisk:restart', label: '重启', sublabel: '消耗 10 电' },
+ { id: 'highRisk:lockdownFloor', label: '封锁楼层', sublabel: '消耗 12 电' },
+ ],
+};
+
export function getCanvasVisibleActionButtons(state) {
if (state.inspection?.status === 'pending') {
- return [
+ if (Number(state.tutorialStep || 0) < 2) return [
{ id: 'reportNormal', label: t('ui.reportNormal'), sublabel: '画面数据一致', decision: 'normal' },
{ id: 'reportAnomaly', label: t('ui.reportAnomaly'), sublabel: '发现任意矛盾', decision: 'anomaly' },
];
+ if (Number(state.tutorialStep || 0) === 3) return ROUND_ACTIONS.quick;
+ return ROUND_ACTIONS[state?.night?.roundType] || ROUND_ACTIONS.quick;
}
const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
@@ -778,6 +985,28 @@ export function getCanvasVisibleActionButtons(state) {
return [{ id: 'standby', label: t('ui.standby'), sublabel: '监控自动运行', disabled: true, wide: true }];
}
+function drawTools(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).tools;
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (layout.w - 24 - layout.gap * 2) / 3;
+ tools.forEach((tool, index) => {
+ const x = layout.x + 12 + index * (buttonW + layout.gap);
+ ctx.save();
+ if (tool.disabled) ctx.globalAlpha = 0.42;
+ roundRect(x, layout.y, buttonW, layout.h, 2, '#101716', tool.disabled ? COLORS.line : 'rgba(132,185,176,0.62)');
+ ctx.fillStyle = tool.disabled ? COLORS.muted : COLORS.cyan;
+ ctx.font = 'bold 22px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(tool.label, x + buttonW / 2, layout.y + 31);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '18px "Microsoft YaHei", sans-serif';
+ ctx.fillText(tool.meta, x + buttonW / 2, layout.y + 58);
+ drawPressShade(x, layout.y, buttonW, layout.h, getPressDepth(tool.id));
+ ctx.restore();
+ });
+ ctx.textAlign = 'left';
+}
+
// ── 绘制操作按钮 ──
function drawActions(state) {
const layout = getCanvasLayout(DH, safeInsetTop).actions;
@@ -788,8 +1017,8 @@ function drawActions(state) {
ctx.fillText(state.activeAnomaly && state.inspection?.status !== 'pending' ? '系统处置' : '当前判断', x + 24, y + 31);
const btns = getCanvasVisibleActionButtons(state);
- const columns = btns.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? w - 32 : (w - 32 - gap) / 2;
+ const columns = btns.length === 1 ? 1 : btns.length === 6 ? 6 : btns.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? w - 32 : (w - 32 - gap * (columns - 1)) / columns;
btns.forEach((btn, i) => {
ctx.save();
if (btn.disabled) ctx.globalAlpha = 0.48;
@@ -821,17 +1050,18 @@ function drawActions(state) {
ctx.shadowBlur = btn.disabled ? 0 : 12;
ctx.fillStyle = accent;
ctx.beginPath();
- ctx.arc(bx + buttonW / 2, by + 31, 9, 0, Math.PI * 2);
+ ctx.arc(bx + buttonW / 2, by + 18, 7, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = COLORS.text;
- ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.font = 'bold 28px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
- ctx.fillText(btn.label, bx + buttonW / 2, by + 94);
+ ctx.fillText(btn.label, bx + buttonW / 2, by + 57);
ctx.fillStyle = '#b5b8b1';
- ctx.font = '24px "Microsoft YaHei", sans-serif';
- ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 132);
+ ctx.font = '19px "Microsoft YaHei", sans-serif';
+ ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 86, buttonW - 20);
+ drawPressShade(bx, by, buttonW, buttonH, getPressDepth(btn.id));
const guidedIndex = Number(state.tutorialStep || 0);
const guided = (state.inspection?.status === 'pending'
@@ -878,6 +1108,59 @@ function drawLogs(state) {
});
}
+export function getCanvasOverlayCloseButton(height = 1334, safeTop = 0) {
+ const x = 55, w = 640, h = 430;
+ const y = Math.max(150 + safeTop, (height - h) / 2);
+ return { x: x + 32, y: y + h - 88, w: w - 64, h: 60 };
+}
+
+export function getCanvasOverlayModel(state) {
+ if (state?.night?.overlay === 'protocolQuery') {
+ return {
+ type: 'protocolQuery',
+ title: '夜班协议查询',
+ lines: (state.night.protocolQuery || []).map(item => item.text || item.id),
+ action: 'closeOverlay',
+ };
+ }
+ if (state?.night?.overlay === 'debrief' && state.night.debrief) {
+ const { summary = {}, ending = {} } = state.night.debrief;
+ return {
+ type: 'debrief',
+ title: `局后复盘 · ${ending.name || '未决记录'}`,
+ lines: [
+ `判断 ${summary.decisions || 0} 次 · 准确率 ${Math.round((summary.accuracy || 0) * 100)}%`,
+ `污染峰值 ${summary.peakContamination || 0}`,
+ ending.summary || '',
+ ].filter(Boolean),
+ action: 'closeOverlay',
+ };
+ }
+ return null;
+}
+
+function drawNightOverlay(state) {
+ const model = getCanvasOverlayModel(state);
+ if (!model) return;
+ ctx.fillStyle = 'rgba(0,0,0,0.76)';
+ ctx.fillRect(0, 0, DW, DH);
+ const x = 55, w = 640, h = 430, y = Math.max(150 + safeInsetTop, (DH - h) / 2);
+ drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.72)');
+ ctx.fillStyle = COLORS.amber;
+ ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.fillText(model.title, x + 32, y + 58, w - 64);
+ ctx.fillStyle = COLORS.text;
+ ctx.font = '26px "Microsoft YaHei", sans-serif';
+ model.lines.forEach((line, index) => wrapText(`${index + 1}. ${line}`, x + 32, y + 112 + index * 62, w - 64, 32));
+ const closeButton = getCanvasOverlayCloseButton(DH, safeInsetTop);
+ roundRect(closeButton.x, closeButton.y, closeButton.w, closeButton.h, 3, '#17352a', 'rgba(121,214,163,0.72)');
+ ctx.fillStyle = COLORS.green;
+ ctx.font = 'bold 26px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('返回监控', closeButton.x + closeButton.w / 2, closeButton.y + 39);
+ ctx.textAlign = 'left';
+}
+
// ── 绘制失败弹窗 ──
function drawFailureOverlay(state) {
if (!state.gameOver) return;
@@ -1098,11 +1381,42 @@ function wrapText(text, x, y, maxWidth, lineHeight) {
}
}
+// ── 按压反馈 ──
+const pressFx = new Map();
+const PRESS_FX_MS = 180;
+
+export function noteCanvasPress(id) {
+ if (id) pressFx.set(id, Date.now());
+}
+
+function getPressDepth(id) {
+ const at = pressFx.get(id);
+ if (!Number.isFinite(at)) return 0;
+ const age = Date.now() - at;
+ if (age > PRESS_FX_MS) {
+ pressFx.delete(id);
+ return 0;
+ }
+ return 1 - age / PRESS_FX_MS;
+}
+
+function drawPressShade(x, y, w, h, depth) {
+ if (depth <= 0) return;
+ ctx.save();
+ ctx.globalAlpha = 0.3 * depth;
+ roundRect(x + 2, y + 2, w - 4, h - 4, 2, '#000000');
+ ctx.globalAlpha = 0.5 * depth;
+ ctx.strokeStyle = 'rgba(255,255,255,0.75)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(x + 5, y + 5, w - 10, h - 10);
+ ctx.restore();
+}
+
// ── 点击检测 ──
let clickHandlers = {};
export function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
- const { onAdRevive, onRestart, onAction, onDecision, onToggleMute, onStart, onSidebar } = callbacks;
+ const { onAdRevive, onRestart, onAction, onDecision, onTool, onCameraSwitch, onToggleMute, onStart, onSidebar } = callbacks;
const inside = (rect) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
const muteControl = getCanvasMuteControl(DH, safeInsetTop, viewState.started !== false);
if (!state.gameOver && inside(muteControl)) {
@@ -1119,6 +1433,11 @@ export function onCanvasClick(x, y, state, callbacks, viewState = { started: tru
if (viewState.paused === true) return;
+ if (getCanvasOverlayModel(state)) {
+ if (inside(getCanvasOverlayCloseButton(DH, safeInsetTop))) onAction?.('closeOverlay');
+ return;
+ }
+
// 失败弹窗按钮检测
if (state.gameOver) {
const cardW = 640, cardH = 520;
@@ -1151,16 +1470,47 @@ export function onCanvasClick(x, y, state, callbacks, viewState = { started: tru
return;
}
- // V4 双选任务点击检测,与绘制布局共用同一组按钮数据。
+ const cameraLayout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const cameraTabs = getCanvasCameraTabs(state);
+ const cameraHit = { ...cameraLayout, y: cameraLayout.hitY ?? cameraLayout.y, h: cameraLayout.hitH ?? cameraLayout.h };
+ if (cameraTabs.length && inside(cameraHit)) {
+ const tabW = (cameraLayout.w - cameraLayout.gap * (cameraTabs.length - 1)) / cameraTabs.length;
+ for (let index = 0; index < cameraTabs.length; index += 1) {
+ const tabX = cameraLayout.x + index * (tabW + cameraLayout.gap);
+ if (x >= tabX && x <= tabX + tabW) {
+ noteCanvasPress(cameraTabs[index].id);
+ onCameraSwitch?.(cameraTabs[index].id);
+ }
+ }
+ return;
+ }
+
+ const toolLayout = getCanvasLayout(DH, safeInsetTop).tools;
+ const toolHit = { ...toolLayout, y: toolLayout.hitY ?? toolLayout.y, h: toolLayout.hitH ?? toolLayout.h };
+ if (inside(toolHit)) {
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (toolLayout.w - 24 - toolLayout.gap * 2) / 3;
+ for (let index = 0; index < tools.length; index += 1) {
+ const toolX = toolLayout.x + 12 + index * (buttonW + toolLayout.gap);
+ if (x >= toolX && x <= toolX + buttonW && !tools[index].disabled) {
+ noteCanvasPress(tools[index].id);
+ onTool?.(tools[index].id);
+ }
+ }
+ return;
+ }
+
+ // V5 动态任务点击检测,与绘制布局共用同一组按钮数据。
const layout = getCanvasLayout(DH, safeInsetTop).actions;
const buttons = getCanvasVisibleActionButtons(state);
- const columns = buttons.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap) / 2;
+ const columns = buttons.length === 1 ? 1 : buttons.length === 6 ? 6 : buttons.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap * (columns - 1)) / columns;
for (let i = 0; i < buttons.length; i += 1) {
const bx = layout.x + 16 + (i % columns) * (buttonW + layout.gap);
const by = layout.startY;
if (x >= bx && x <= bx + buttonW && y >= by && y <= by + layout.buttonH) {
if (buttons[i].disabled) return;
+ noteCanvasPress(buttons[i].id);
if (buttons[i].decision) {
onDecision?.(buttons[i].decision);
} else onAction?.(buttons[i].id);
@@ -1175,19 +1525,22 @@ export function render(state, viewState = { started: true, paused: false }) {
drawBackground();
drawTopbar(state);
- drawRuleStrip(state);
+ drawProtocolBar(state);
+ drawCameraTabs(state);
drawMonitor(state, viewState.cctvMotion);
drawReadings(state, viewState.cctvMotion);
+ drawTools(state);
drawActions(state);
drawFeedback(state);
drawFailureOverlay(state);
+ drawNightOverlay(state);
if (viewState.started === false) drawStartOverlay(viewState);
else if (viewState.paused === true) drawPauseOverlay();
if (!state.gameOver) drawMuteControl(viewState);
}
// ── 初始化 ──
-export function init(canvasEl, systemInfo = {}) {
+export function init(canvasEl, systemInfo = {}, options = {}) {
canvas = canvasEl;
ctx = canvas.getContext('2d');
@@ -1200,12 +1553,14 @@ export function init(canvasEl, systemInfo = {}) {
canvas.height = metrics.height;
scale = 1;
- const imageFactory = () => {
+ // 小游戏运行时优先 wx/tt createImage;浏览器验收 harness 通过 options.imageFactory 注入 DOM Image,
+ // 使发布 bundle 不含任何 document/window 引用。
+ const imageFactory = options.imageFactory || (() => {
if (typeof tt !== 'undefined' && typeof tt.createImage === 'function') return tt.createImage();
if (typeof wx !== 'undefined' && typeof wx.createImage === 'function') return wx.createImage();
if (typeof canvas.createImage === 'function') return canvas.createImage();
return null;
- };
+ });
assetStore = createCanvasAssetStore(imageFactory);
assetStore.preload();
diff --git a/platform/miniGameAudio.js b/platform/miniGameAudio.js
index c51feb3..e84c650 100644
--- a/platform/miniGameAudio.js
+++ b/platform/miniGameAudio.js
@@ -9,8 +9,37 @@ const SOURCES = Object.freeze({
wrong: 'audio/wrong.wav',
});
+const MUSIC_SOURCES = Object.freeze({
+ calm: 'audio/bgm-night-shift-loop.wav',
+ pressure: 'audio/bgm-anomaly-pressure-loop.wav',
+});
+
+const V5_FEEDBACK_PROFILES = Object.freeze({
+ camera: Object.freeze({ cue: 'click', haptic: 'light' }),
+ 'tool:thermal': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'tool:replay': Object.freeze({ cue: 'motor', haptic: 'light' }),
+ 'tool:protocol': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'protocol:close': Object.freeze({ cue: 'release', haptic: 'light' }),
+ 'identity:verify': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'identity:correct': Object.freeze({ cue: 'release', haptic: 'medium' }),
+ 'identity:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'classification:enter': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'classification:correct': Object.freeze({ cue: 'lockdown', haptic: 'medium' }),
+ 'classification:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'highRisk:correct': Object.freeze({ cue: 'lockdown', haptic: 'heavy' }),
+ 'highRisk:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+});
+
+export function getV5FeedbackProfile(kind) {
+ const profile = V5_FEEDBACK_PROFILES[kind] || V5_FEEDBACK_PROFILES.camera;
+ return { ...profile };
+}
+
export function createMiniGameAudio(api) {
const contexts = new Map();
+ let musicContext = null;
+ let musicState = null;
+ let musicPaused = true;
let muted = false;
function getContext(cue) {
@@ -25,7 +54,27 @@ export function createMiniGameAudio(api) {
return context;
}
- return {
+ function getMusicContext() {
+ if (musicContext) return musicContext;
+ if (!api || typeof api.createInnerAudioContext !== 'function') return null;
+ musicContext = api.createInnerAudioContext();
+ musicContext.autoplay = false;
+ musicContext.loop = true;
+ musicContext.volume = 0.12;
+ return musicContext;
+ }
+
+ function safePlay(context) {
+ try {
+ const result = context?.play?.();
+ result?.catch?.(() => {});
+ return Boolean(context && typeof context.play === 'function');
+ } catch {
+ return false;
+ }
+ }
+
+ const controller = {
play(cue) {
if (muted || !SOURCES[cue]) return false;
const context = getContext(cue);
@@ -33,27 +82,69 @@ export function createMiniGameAudio(api) {
try {
context.stop?.();
context.seek?.(0);
- const result = context.play();
- result?.catch?.(() => {});
- return true;
+ return safePlay(context);
} catch {
return false;
}
},
+ setMusicState(nextState) {
+ if (!MUSIC_SOURCES[nextState]) return false;
+ musicState = nextState;
+ if (muted) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ if (context.src !== MUSIC_SOURCES[nextState]) {
+ context.stop?.();
+ context.src = MUSIC_SOURCES[nextState];
+ context.loop = true;
+ context.volume = nextState === 'pressure' ? 0.10 : 0.12;
+ context.seek?.(0);
+ }
+ musicPaused = false;
+ return safePlay(context);
+ },
+ pauseMusic() {
+ musicContext?.pause?.();
+ musicPaused = true;
+ },
+ resumeMusic() {
+ if (muted || !musicState || !musicPaused) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ context.src = MUSIC_SOURCES[musicState];
+ context.loop = true;
+ context.volume = musicState === 'pressure' ? 0.10 : 0.12;
+ musicPaused = false;
+ return safePlay(context);
+ },
+ stopMusic() {
+ musicContext?.stop?.();
+ musicPaused = true;
+ },
+ getMusicState() {
+ return musicState;
+ },
stopAll() {
for (const context of contexts.values()) context.stop?.();
+ controller.stopMusic();
},
destroy() {
for (const context of contexts.values()) context.destroy?.();
contexts.clear();
+ musicContext?.destroy?.();
+ musicContext = null;
+ musicState = null;
+ musicPaused = true;
},
setMuted(value) {
muted = Boolean(value);
- if (muted) this.stopAll();
+ if (muted) controller.stopAll();
return muted;
},
isMuted() {
return muted;
},
};
+
+ return controller;
}
diff --git a/platform/miniGameRuntime.js b/platform/miniGameRuntime.js
index 4c0cf37..96b444a 100644
--- a/platform/miniGameRuntime.js
+++ b/platform/miniGameRuntime.js
@@ -26,10 +26,21 @@ import {
} from '../src/runtimeSession.js';
import { init, onCanvasClick, render } from './canvasRenderer.js';
import { bindMiniGameLifecycle, checkDouyinSidebar, navigateToDouyinSidebar } from './douyinIntegration.js';
-import { createMiniGameAudio } from './miniGameAudio.js';
+import { createMiniGameAudio, getV5FeedbackProfile } from './miniGameAudio.js';
import { createMiniGameClock } from './miniGameClock.js';
import { createCctvMotionController } from './cctvMotion.js';
import { shouldApplyReward } from '../src/rewardGuard.js';
+import { switchCamera, useInvestigationTool } from '../src/investigationTools.js';
+import { scheduleNextNightShift, advanceCurrentNightEventChain } from '../src/nightScheduler.js';
+import {
+ classifyCurrentShift,
+ closeProtocolQuery,
+ createNightDebrief,
+ openProtocolQuery,
+ resolveCurrentHighRisk,
+ resolveIdentityDecision,
+ verifyCurrentIdentity,
+} from '../src/nightInteraction.js';
function getHostApi() {
if (typeof wx !== 'undefined' && wx) return wx;
@@ -145,6 +156,11 @@ export function startMiniGame() {
const vibrate = (type = 'light') => {
try { api.vibrateShort?.({ type }); } catch { /* optional haptics */ }
};
+ const playV5Feedback = (kind) => {
+ const profile = getV5FeedbackProfile(kind);
+ audio.play(profile.cue);
+ vibrate(profile.haptic);
+ };
const audioStorageKey = 'minigame_audio_muted_v1';
try {
audio.setMuted(api.getStorageSync?.(audioStorageKey) === true);
@@ -157,7 +173,7 @@ export function startMiniGame() {
return available;
});
refreshSidebarAvailability();
- let session = createRuntimeSession();
+ let session = createRuntimeSession({ content: __V5_CONTENT__ });
let state = session.state;
let nextAnomalyAt = session.nextAnomalyAt;
let lastSnapshotAt = 0;
@@ -179,6 +195,7 @@ export function startMiniGame() {
if (!lifecycleHidden) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
}
@@ -247,6 +264,9 @@ export function startMiniGame() {
function toggleMute() {
const muted = audio.setMuted(!audio.isMuted());
+ if (!muted && clock.isStarted() && !state.gameOver && !lifecycleHidden && !adPauseActive) {
+ audio.resumeMusic() || audio.setMusicState(state.activeAnomaly ? 'pressure' : 'calm');
+ }
try {
api.setStorageSync?.(audioStorageKey, muted);
} catch {
@@ -257,6 +277,7 @@ export function startMiniGame() {
function start() {
if (clock.isStarted()) return;
audio.play('boot');
+ audio.setMusicState('calm');
state = openInspection(state, {
id: `baseline-${runToken}`,
kind: 'normal',
@@ -276,8 +297,9 @@ export function startMiniGame() {
function restart() {
runToken += 1;
audio.play('boot');
+ audio.setMusicState('calm');
clock.start();
- session = restartRuntimeSession({ state });
+ session = restartRuntimeSession({ state }, { content: __V5_CONTENT__ });
state = session.state;
cctvMotion.reset();
state = openInspection(state, {
@@ -292,6 +314,22 @@ export function startMiniGame() {
failureRecorded = false;
}
+ function openScheduledNightInspection(nextState) {
+ const shift = nextState.night?.currentShift;
+ if (!shift) return nextState;
+ return openInspection(nextState, {
+ id: `night-${shift.id}-${nextState.night.shiftIndex}`,
+ kind: shift.shiftKind === 'anomaly' || shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ title: shift.name || shift.id,
+ duration: shift.duration ?? 10,
+ });
+ }
+
+ function scheduleFollowingNightShift(currentState, outcome) {
+ const advanced = advanceCurrentNightEventChain(currentState, __V5_CONTENT__, outcome);
+ return openScheduledNightInspection(scheduleNextNightShift(advanced.state, __V5_CONTENT__));
+ }
+
function resolveActiveAnomalyAutomatically(feedbackKey) {
if (!state.activeAnomaly) return false;
const automaticAction = getAnomalyResolutionAction(state.activeAnomaly);
@@ -349,13 +387,70 @@ export function startMiniGame() {
}
// 教学第二班必须直接进入异常,不允许中间插入随机正常巡检。
const tutorialStep = Number(state.tutorialStep || 0);
+ if (tutorialStep === 4 && state.night?.activeEventChainId) {
+ state = openScheduledNightInspection(scheduleNextNightShift(state, __V5_CONTENT__));
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ return;
+ }
nextNormalInspectionAt = tutorialStep === 1
? Number.POSITIVE_INFINITY
: state.elapsed + (tutorialStep === 3 ? 2 : 4);
}
function handleAction(actionId) {
- if (state.gameOver) return;
+ if (state.gameOver && actionId !== 'closeOverlay') return;
+ if (actionId === 'closeOverlay') {
+ state = closeProtocolQuery(state);
+ playV5Feedback('protocol:close');
+ return;
+ }
+ if (actionId === 'identityVerify') {
+ const result = verifyCurrentIdentity(state);
+ if (!result.accepted) {
+ playV5Feedback('identity:wrong');
+ return;
+ }
+ state = result.state;
+ playV5Feedback('identity:verify');
+ return;
+ }
+ if (actionId === 'identityRelease' || actionId === 'identityReject') {
+ const result = resolveIdentityDecision(state, actionId === 'identityRelease' ? 'release' : 'reject');
+ if (!result.accepted) return;
+ playV5Feedback(`identity:${result.correct ? 'correct' : 'wrong'}`);
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ return;
+ }
+ if (actionId === 'enterClassification' || actionId === 'markSuspicion') {
+ state = {
+ ...state,
+ night: { ...state.night, roundType: 'classification' },
+ lastFeedback: '请选择异常分类',
+ };
+ playV5Feedback('classification:enter');
+ return;
+ }
+ if (actionId.startsWith('classify:')) {
+ const result = classifyCurrentShift(state, actionId.slice('classify:'.length));
+ if (!result.accepted) return;
+ state = result.state;
+ playV5Feedback(`classification:${result.correct ? 'correct' : 'wrong'}`);
+ if (state.night.roundType !== 'highRisk') {
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ }
+ return;
+ }
+ if (actionId.startsWith('highRisk:')) {
+ const result = resolveCurrentHighRisk(state, actionId.slice('highRisk:'.length));
+ if (!result.accepted) {
+ playV5Feedback('highRisk:wrong');
+ return;
+ }
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ playV5Feedback(`highRisk:${result.correct ? 'correct' : 'wrong'}`);
+ return;
+ }
if (actionId === 'unlockHiddenLog') {
decodeAd({ runToken });
return;
@@ -373,6 +468,42 @@ export function startMiniGame() {
}
}
+ function handleCameraSwitch(cameraId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = switchCamera(state.investigation, cameraId, {
+ ...shift,
+ cameras: Object.keys(shift.evidence?.cameras || {}),
+ });
+ if (!result.accepted) return;
+ state = { ...state, investigation: result.state };
+ playV5Feedback('camera');
+ }
+
+ function handleTool(toolId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = useInvestigationTool(state.investigation, toolId, shift);
+ if (!result.accepted) {
+ audio.play('wrong');
+ vibrate('heavy');
+ return;
+ }
+ const count = Array.isArray(result.discoveredEvidence)
+ ? result.discoveredEvidence.length
+ : result.discoveredEvidence ? 1 : 0;
+ state = {
+ ...state,
+ investigation: result.state,
+ power: result.state.power,
+ lastFeedback: toolId === 'protocol'
+ ? `已调取 ${count} 条当前夜班协议`
+ : `${toolId === 'thermal' ? '热源扫描' : '三秒回放'}发现 ${count} 条证据`,
+ };
+ if (toolId === 'protocol') state = openProtocolQuery(state);
+ playV5Feedback(`tool:${toolId}`);
+ }
+
function handleAd(kind) {
if (kind === 'truth') {
truthAd({ runToken });
@@ -390,6 +521,8 @@ export function startMiniGame() {
onCanvasClick(x, y, state, {
onAction: handleAction,
onDecision: handleDecision,
+ onTool: handleTool,
+ onCameraSwitch: handleCameraSwitch,
onToggleMute: toggleMute,
onAdRevive: handleAd,
onRestart: restart,
@@ -412,6 +545,7 @@ export function startMiniGame() {
if (!adPauseActive) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
},
});
@@ -423,11 +557,20 @@ export function startMiniGame() {
for (let i = 0; i < delta; i += 1) {
state = tickState(state, 1);
if (!state.gameOver) {
+ const expiredNightShift = Number(state.tutorialStep || 0) >= 4
+ && Boolean(state.night?.activeEventChainId)
+ && Boolean(state.night?.currentShift?.id);
const expiredKind = state.inspection?.kind;
const expiry = expireInspection(state);
state = expiry.state;
if (expiry.timedOut) {
audio.play(expiry.coached ? 'wrong' : 'result');
+ if (expiredNightShift && !state.gameOver) {
+ state = scheduleFollowingNightShift(state, { correct: false });
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ continue;
+ }
if (expiredKind === 'anomaly' && state.activeAnomaly) {
resolveActiveAnomalyAutomatically('ui.autoResolutionTimeout');
}
@@ -485,11 +628,26 @@ export function startMiniGame() {
}
if (state.gameOver && !failureRecorded) {
state = state.result === 'success' ? recordSuccessfulShift(state) : recordFailure(state);
+ state = {
+ ...state,
+ night: {
+ ...state.night,
+ overlay: 'debrief',
+ debrief: createNightDebrief(state, __V5_CONTENT__.endings),
+ },
+ };
audio.play('result');
failureRecorded = true;
}
}
+ if (state.gameOver) {
+ audio.stopMusic();
+ } else if (clock.isStarted() && !lifecycleHidden && !adPauseActive && !audio.isMuted()) {
+ const desiredMusic = state.activeAnomaly ? 'pressure' : 'calm';
+ if (audio.getMusicState() !== desiredMusic) audio.setMusicState(desiredMusic);
+ }
+
render(state, getViewState());
nextFrame(api, update);
}
diff --git a/schemas/anomaly-content.schema.json b/schemas/anomaly-content.schema.json
new file mode 100644
index 0000000..1d4a63f
--- /dev/null
+++ b/schemas/anomaly-content.schema.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "anomaly-content.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "name", "category", "roundType", "difficulty", "duration", "decision", "screenData", "panelData", "primaryConflict", "explanation", "visualState", "audioCue", "resolutionAction", "highRisk", "availableTools", "protocolTags", "protocolDependent", "contaminationEffects", "silentEvidence", "normalVariants", "evidence"],
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "category": { "enum": ["person", "count", "space", "time", "device", "dynamic"] },
+ "roundType": { "enum": ["quick", "investigation", "identity", "highRisk"] },
+ "difficulty": { "type": "integer", "minimum": 1, "maximum": 3 },
+ "duration": { "type": "number", "exclusiveMinimum": 0 },
+ "decision": { "const": "anomaly" },
+ "screenData": { "type": "object" },
+ "panelData": { "type": "object" },
+ "primaryConflict": { "type": "string", "minLength": 1 },
+ "explanation": { "type": "string", "minLength": 1 },
+ "visualState": { "type": "string" },
+ "audioCue": { "type": ["string", "null"] },
+ "resolutionAction": { "type": "string" },
+ "highRisk": { "type": "boolean" },
+ "availableTools": { "type": "array", "items": { "enum": ["camera", "thermal", "replay", "protocol"] }, "uniqueItems": true },
+ "protocolTags": { "type": "array", "items": { "type": "string" } },
+ "protocolDependent": { "type": "boolean" },
+ "contaminationEffects": { "type": "object" },
+ "silentEvidence": { "type": "array", "minItems": 2, "items": { "type": "string" }, "uniqueItems": true },
+ "normalVariants": { "type": "array", "minItems": 2, "items": { "type": "string" } },
+ "evidence": { "type": "object" }
+ }
+}
diff --git a/schemas/ending.schema.json b/schemas/ending.schema.json
new file mode 100644
index 0000000..30c2e4f
--- /dev/null
+++ b/schemas/ending.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "ending.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "name", "priority", "conditions", "summary"],
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "priority": { "type": "integer", "minimum": 0 },
+ "conditions": { "type": "object" },
+ "summary": { "type": "string" }
+ }
+}
diff --git a/schemas/event-chain.schema.json b/schemas/event-chain.schema.json
new file mode 100644
index 0000000..15844d1
--- /dev/null
+++ b/schemas/event-chain.schema.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "event-chain.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "steps", "initialFlags", "consequences"],
+ "properties": {
+ "id": { "type": "string" },
+ "steps": { "type": "array", "minItems": 2, "items": { "type": "object" } },
+ "initialFlags": { "type": "array", "items": { "type": "string" } },
+ "consequences": { "type": "array", "items": { "type": "object" } }
+ }
+}
diff --git a/schemas/normal-shift.schema.json b/schemas/normal-shift.schema.json
new file mode 100644
index 0000000..dd9a47e
--- /dev/null
+++ b/schemas/normal-shift.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "normal-shift.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "roundType", "screenData", "panelData", "protocolTags", "passengerIds", "evidence"],
+ "properties": {
+ "id": { "type": "string" },
+ "roundType": { "enum": ["quick", "investigation", "identity", "highRisk"] },
+ "screenData": { "type": "object" },
+ "panelData": { "type": "object" },
+ "protocolTags": { "type": "array", "items": { "type": "string" } },
+ "passengerIds": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
+ "evidence": { "type": "object" }
+ }
+}
diff --git a/schemas/passenger.schema.json b/schemas/passenger.schema.json
new file mode 100644
index 0000000..e771837
--- /dev/null
+++ b/schemas/passenger.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "passenger.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "name", "role", "badge", "allowedFloors", "countMode", "verificationPaths"],
+ "properties": {
+ "id": { "type": "string", "minLength": 1 },
+ "name": { "type": "string", "minLength": 1 },
+ "role": { "enum": ["maintenance", "resident", "courier", "cleaner", "security"] },
+ "badge": { "type": "string", "minLength": 1 },
+ "allowedFloors": { "type": "array", "minItems": 1, "items": { "type": "string" }, "uniqueItems": true },
+ "countMode": { "enum": ["normal", "ignore"] },
+ "verificationPaths": { "type": "array", "minItems": 2, "items": { "type": "string" }, "uniqueItems": true }
+ }
+}
diff --git a/schemas/protocol.schema.json b/schemas/protocol.schema.json
new file mode 100644
index 0000000..1c9ca01
--- /dev/null
+++ b/schemas/protocol.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "protocol.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "category", "text", "protocolTags", "condition", "decision", "verificationPaths"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
+ "category": { "enum": ["floor", "personnel", "time", "device", "identity"] },
+ "text": { "type": "string", "minLength": 1, "maxLength": 44 },
+ "protocolTags": { "type": "array", "minItems": 1, "items": { "type": "string" }, "uniqueItems": true },
+ "condition": { "type": "object" },
+ "decision": { "enum": ["release", "lockdown"] },
+ "verificationPaths": { "type": "array", "minItems": 1, "items": { "type": "string" }, "uniqueItems": true }
+ }
+}
diff --git a/scripts/prepare-v5-ui-assets.py b/scripts/prepare-v5-ui-assets.py
new file mode 100644
index 0000000..d588d90
--- /dev/null
+++ b/scripts/prepare-v5-ui-assets.py
@@ -0,0 +1,32 @@
+from pathlib import Path
+from PIL import Image, ImageEnhance, ImageOps
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / 'asset-handoff-hermes-2026-07-12' / 'ui-v5-full' / 'source-gpt-image'
+OUTPUT = ROOT / 'games' / 'find-anomaly' / 'elevator-console' / 'assets' / 'abnormal_elevator_visual_assets' / 'mobile_cctv_states'
+
+SCENES = {
+ 'v5_00_protocol_start': ('00_protocol-start-source.png', (150, 270, 900, 900)),
+ 'v5_01_quick': ('01_quick-source.png', (60, 260, 980, 930)),
+ 'v5_02_investigation': ('02_investigation-source.png', (55, 325, 975, 980)),
+ 'v5_03_identity': ('03_identity-source.png', (90, 250, 930, 850)),
+ 'v5_04_classification': ('04_classification-source.png', (85, 220, 940, 820)),
+ 'v5_05_high_risk': ('05_high-risk-source.png', (90, 225, 935, 970)),
+ 'v5_06_protocol_query': ('06_protocol-query-source.png', (90, 220, 935, 950)),
+ 'v5_07_debrief': ('07_debrief-source.png', (95, 165, 925, 610)),
+}
+
+
+def main():
+ OUTPUT.mkdir(parents=True, exist_ok=True)
+ for name, (filename, crop) in SCENES.items():
+ source = Image.open(SOURCE / filename).convert('RGB').crop(crop)
+ scene = ImageOps.fit(source, (720, 420), Image.Resampling.LANCZOS)
+ scene = ImageEnhance.Contrast(ImageEnhance.Color(scene).enhance(0.45)).enhance(1.14)
+ destination = OUTPUT / f'{name}_mobile.png'
+ scene.save(destination, optimize=True)
+ print(f'[v5-assets] {destination.relative_to(ROOT)} {scene.size}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/v5-canvas-acceptance.html b/scripts/v5-canvas-acceptance.html
new file mode 100644
index 0000000..b3ab71e
--- /dev/null
+++ b/scripts/v5-canvas-acceptance.html
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+ Game001 V5 Canvas Acceptance
+
+
+
+
+
+
+
diff --git a/scripts/validate-v5-content.mjs b/scripts/validate-v5-content.mjs
new file mode 100644
index 0000000..7e6f3b9
--- /dev/null
+++ b/scripts/validate-v5-content.mjs
@@ -0,0 +1,64 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const read = relative => JSON.parse(fs.readFileSync(path.join(ROOT, relative), 'utf8'));
+const errors = [];
+const fail = (path, message) => errors.push(`${path}: ${message}`);
+
+const pairs = [
+ ['src/content/protocols.json', 'schemas/protocol.schema.json'],
+ ['src/content/normalShifts.json', 'schemas/normal-shift.schema.json'],
+ ['src/content/anomalies.json', 'schemas/anomaly-content.schema.json'],
+ ['src/content/eventChains.json', 'schemas/event-chain.schema.json'],
+ ['src/content/passengers.json', 'schemas/passenger.schema.json'],
+ ['src/content/endings.json', 'schemas/ending.schema.json'],
+];
+
+function matchesType(value, expected) {
+ if (Array.isArray(expected)) return expected.some(type => matchesType(value, type));
+ if (expected === 'array') return Array.isArray(value);
+ if (expected === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
+ if (expected === 'integer') return Number.isInteger(value);
+ if (expected === 'null') return value === null;
+ return typeof value === expected;
+}
+
+function validateEntry(entry, schema, location) {
+ if (!matchesType(entry, schema.type)) return fail(location, `expected ${schema.type}`);
+ for (const field of schema.required || []) {
+ if (!(field in entry)) fail(location, `missing required field ${field}`);
+ }
+ if (schema.additionalProperties === false) {
+ for (const field of Object.keys(entry)) {
+ if (!(field in (schema.properties || {}))) fail(location, `unknown field ${field}`);
+ }
+ }
+ for (const [field, rule] of Object.entries(schema.properties || {})) {
+ if (!(field in entry)) continue;
+ const value = entry[field];
+ if (rule.type && !matchesType(value, rule.type)) fail(`${location}.${field}`, `expected ${JSON.stringify(rule.type)}`);
+ if (rule.enum && !rule.enum.includes(value)) fail(`${location}.${field}`, `must be one of ${rule.enum.join(', ')}`);
+ if ('const' in rule && value !== rule.const) fail(`${location}.${field}`, `must equal ${rule.const}`);
+ if (typeof value === 'string' && rule.maxLength && value.length > rule.maxLength) fail(`${location}.${field}`, `exceeds ${rule.maxLength} chars`);
+ if (Array.isArray(value) && rule.minItems && value.length < rule.minItems) fail(`${location}.${field}`, `requires ${rule.minItems} items`);
+ }
+}
+
+for (const [contentPath, schemaPath] of pairs) {
+ const content = read(contentPath);
+ const schema = read(schemaPath);
+ if (!Array.isArray(content)) {
+ fail(contentPath, 'content root must be an array');
+ continue;
+ }
+ content.forEach((entry, index) => validateEntry(entry, schema, `${contentPath}[${index}]`));
+ console.log(`[v5-content] ${contentPath}: ${content.length} entries`);
+}
+
+if (errors.length) {
+ console.error(errors.map(error => `- ${error}`).join('\n'));
+ process.exit(1);
+}
+console.log('[v5-content] ✅ schemas and content containers valid');
diff --git a/src/audio.js b/src/audio.js
index facdc77..70b40d1 100644
--- a/src/audio.js
+++ b/src/audio.js
@@ -7,6 +7,12 @@
let ctx = null;
let muted = false;
+let music = null;
+let musicState = null;
+const MUSIC_SOURCES = Object.freeze({
+ calm: 'assets/minigame-audio/bgm-night-shift-loop.wav',
+ pressure: 'assets/minigame-audio/bgm-anomaly-pressure-loop.wav',
+});
export const AUDIO_LAYERS = Object.freeze({
button: Object.freeze({ kind: 'beep', freq: 800, duration: 0.06, type: 'square', volume: 0.06 }),
@@ -24,9 +30,55 @@ export const AUDIO_LAYERS = Object.freeze({
export function setAudioMuted(value) {
muted = Boolean(value);
+ if (muted) pauseMusic();
return muted;
}
+function getMusic() {
+ if (music) return music;
+ if (typeof Audio !== 'function') return null;
+ music = new Audio(MUSIC_SOURCES.calm);
+ music.loop = true;
+ music.preload = 'auto';
+ music.volume = 0.12;
+ return music;
+}
+
+export function setMusicState(nextState) {
+ if (muted || !MUSIC_SOURCES[nextState]) return false;
+ const player = getMusic();
+ if (!player) return false;
+ if (musicState === nextState && player.paused === false) return true;
+ if (musicState !== nextState) {
+ player.pause?.();
+ player.src = MUSIC_SOURCES[nextState];
+ player.currentTime = 0;
+ player.volume = nextState === 'pressure' ? 0.10 : 0.12;
+ musicState = nextState;
+ }
+ player.loop = true;
+ const result = player.play?.();
+ result?.catch?.(() => {});
+ return true;
+}
+
+export function pauseMusic() {
+ music?.pause?.();
+}
+
+export function resumeMusic() {
+ if (muted || !musicState || !music) return false;
+ const result = music.play?.();
+ result?.catch?.(() => {});
+ return true;
+}
+
+export function stopMusic() {
+ if (!music) return;
+ music.pause?.();
+ try { music.currentTime = 0; } catch { /* optional browser behavior */ }
+}
+
export function isAudioMuted() {
return muted;
}
diff --git a/src/contamination.js b/src/contamination.js
new file mode 100644
index 0000000..57ae223
--- /dev/null
+++ b/src/contamination.js
@@ -0,0 +1,70 @@
+function clamp(value, min = 0, max = 100) {
+ return Math.max(min, Math.min(max, Number(value) || 0));
+}
+
+export function getContaminationTier(value) {
+ const normalized = clamp(value);
+ if (normalized >= 76) return 'severe';
+ if (normalized >= 51) return 'medium';
+ if (normalized >= 26) return 'light';
+ return 'normal';
+}
+
+export function createContaminationState(value = 0) {
+ const normalized = clamp(value);
+ return { value: normalized, tier: getContaminationTier(normalized), history: [] };
+}
+
+export function changeContamination(state, delta, reason) {
+ const current = state || createContaminationState();
+ const value = clamp(current.value + Number(delta || 0));
+ return {
+ value,
+ tier: getContaminationTier(value),
+ history: [...(current.history || []), { delta: Number(delta || 0), reason, value }],
+ };
+}
+
+export function applyDecisionContamination(state, decision = {}) {
+ const effects = decision.contaminationEffects || {};
+ const delta = decision.correct === false
+ ? Number(effects.onMiss || 0)
+ : Number(effects.onCorrect || 0);
+ return changeContamination(state, delta, {
+ type: decision.correct === false ? 'wrong-decision' : 'correct-decision',
+ contentId: decision.contentId ?? null,
+ });
+}
+
+export function deriveContaminationEffects(value) {
+ const tier = getContaminationTier(value);
+ const reliability = {
+ normal: {
+ reliable: ['panel', 'cam01', 'cam03', 'cam07', 'thermal', 'replay'],
+ unreliable: [],
+ },
+ light: {
+ reliable: ['panel', 'cam01', 'cam03', 'thermal', 'replay'],
+ unreliable: ['cam07'],
+ },
+ medium: {
+ reliable: ['cam01', 'thermal', 'replay'],
+ unreliable: ['panel', 'cam07'],
+ },
+ severe: {
+ reliable: ['thermal', 'replay'],
+ unreliable: ['panel', 'cam01', 'cam03', 'cam07'],
+ },
+ }[tier];
+ const effects = {
+ tier,
+ chromaticAberration: tier === 'normal' ? 0 : tier === 'light' ? 0.08 : tier === 'medium' ? 0.16 : 0.24,
+ timecodeJitter: tier === 'medium' || tier === 'severe',
+ edgeGhosting: tier !== 'normal',
+ protocolGlyphDropout: tier === 'severe',
+ audioDropout: tier === 'medium' || tier === 'severe',
+ reliableVerificationPaths: reliability.reliable,
+ unreliableVerificationPaths: reliability.unreliable,
+ };
+ return effects;
+}
diff --git a/src/content/anomalies.json b/src/content/anomalies.json
new file mode 100644
index 0000000..671a2c2
--- /dev/null
+++ b/src/content/anomalies.json
@@ -0,0 +1,2766 @@
+[
+ {
+ "id": "person_duplicate_face",
+ "name": "重复面孔",
+ "category": "person",
+ "roundType": "identity",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "duplicate_face",
+ "explanation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "person",
+ "identity"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "cam03"
+ ],
+ "normalVariants": [
+ "normal_shift_01",
+ "normal_shift_02"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "duplicate_face_cam01",
+ "source": "cam01",
+ "conflictKey": "duplicate_face",
+ "observation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "duplicate_face_cam03",
+ "source": "cam03",
+ "conflictKey": "duplicate_face",
+ "observation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "duplicate_face_cam07",
+ "source": "cam07",
+ "conflictKey": "duplicate_face",
+ "observation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "duplicate_face_thermal",
+ "source": "thermal",
+ "conflictKey": "duplicate_face",
+ "observation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "duplicate_face_replay",
+ "source": "replay",
+ "conflictKey": "duplicate_face",
+ "observation": "同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "person_wrong_badge",
+ "name": "错误胸牌",
+ "category": "person",
+ "roundType": "identity",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "wrong_badge",
+ "explanation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "person",
+ "identity"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_02",
+ "normal_shift_03"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "wrong_badge_cam01",
+ "source": "cam01",
+ "conflictKey": "wrong_badge",
+ "observation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "wrong_badge_cam03",
+ "source": "cam03",
+ "conflictKey": "wrong_badge",
+ "observation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "wrong_badge_cam07",
+ "source": "cam07",
+ "conflictKey": "wrong_badge",
+ "observation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "wrong_badge_thermal",
+ "source": "thermal",
+ "conflictKey": "wrong_badge",
+ "observation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "wrong_badge_replay",
+ "source": "replay",
+ "conflictKey": "wrong_badge",
+ "observation": "自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "person_cold_passenger",
+ "name": "无热源人物",
+ "category": "person",
+ "roundType": "investigation",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "cold_passenger",
+ "explanation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "person"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "thermal"
+ ],
+ "normalVariants": [
+ "normal_shift_03",
+ "normal_shift_04"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "cold_passenger_cam01",
+ "source": "cam01",
+ "conflictKey": "cold_passenger",
+ "observation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "cold_passenger_cam03",
+ "source": "cam03",
+ "conflictKey": "cold_passenger",
+ "observation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "cold_passenger_cam07",
+ "source": "cam07",
+ "conflictKey": "cold_passenger",
+ "observation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "cold_passenger_thermal",
+ "source": "thermal",
+ "conflictKey": "cold_passenger",
+ "observation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "contradicts": true
+ },
+ "replay": {
+ "id": "cold_passenger_replay",
+ "source": "replay",
+ "conflictKey": "cold_passenger",
+ "observation": "画面中乘客轮廓清晰,但热源扫描没有生命反应。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "person_unknown_identity",
+ "name": "不存在的工号",
+ "category": "person",
+ "roundType": "identity",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "unknown_identity",
+ "explanation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "person",
+ "identity"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_04",
+ "normal_shift_05"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "unknown_identity_cam01",
+ "source": "cam01",
+ "conflictKey": "unknown_identity",
+ "observation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "unknown_identity_cam03",
+ "source": "cam03",
+ "conflictKey": "unknown_identity",
+ "observation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "unknown_identity_cam07",
+ "source": "cam07",
+ "conflictKey": "unknown_identity",
+ "observation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "unknown_identity_thermal",
+ "source": "thermal",
+ "conflictKey": "unknown_identity",
+ "observation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "unknown_identity_replay",
+ "source": "replay",
+ "conflictKey": "unknown_identity",
+ "observation": "乘客工号不在当夜授权名单,目标楼层却被请求。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "person_shadow_mismatch",
+ "name": "影子人数异常",
+ "category": "person",
+ "roundType": "investigation",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "shadow_mismatch",
+ "explanation": "一名乘客对应两道独立移动的影子。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "person"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_05",
+ "normal_shift_06"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "shadow_mismatch_cam01",
+ "source": "cam01",
+ "conflictKey": "shadow_mismatch",
+ "observation": "一名乘客对应两道独立移动的影子。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "shadow_mismatch_cam03",
+ "source": "cam03",
+ "conflictKey": "shadow_mismatch",
+ "observation": "一名乘客对应两道独立移动的影子。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "shadow_mismatch_cam07",
+ "source": "cam07",
+ "conflictKey": "shadow_mismatch",
+ "observation": "一名乘客对应两道独立移动的影子。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "shadow_mismatch_thermal",
+ "source": "thermal",
+ "conflictKey": "shadow_mismatch",
+ "observation": "一名乘客对应两道独立移动的影子。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "shadow_mismatch_replay",
+ "source": "replay",
+ "conflictKey": "shadow_mismatch",
+ "observation": "一名乘客对应两道独立移动的影子。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "count_panel_undercount",
+ "name": "主控少计一人",
+ "category": "count",
+ "roundType": "quick",
+ "difficulty": 1,
+ "duration": 8,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 7,
+ "passengers": 2,
+ "door": "closed"
+ },
+ "primaryConflict": "panel_undercount",
+ "explanation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "visualState": "14_duplicate_subject",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "count"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "panel"
+ ],
+ "normalVariants": [
+ "normal_shift_06",
+ "normal_shift_07"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "panel_undercount_cam01",
+ "source": "cam01",
+ "conflictKey": "panel_undercount",
+ "observation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "panel_undercount_cam03",
+ "source": "cam03",
+ "conflictKey": "panel_undercount",
+ "observation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "panel_undercount_cam07",
+ "source": "cam07",
+ "conflictKey": "panel_undercount",
+ "observation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "panel_undercount_thermal",
+ "source": "thermal",
+ "conflictKey": "panel_undercount",
+ "observation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "panel_undercount_replay",
+ "source": "replay",
+ "conflictKey": "panel_undercount",
+ "observation": "CAM-01 可见两名乘客,主控只记录一人。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "count_empty_weight",
+ "name": "空厢载重",
+ "category": "count",
+ "roundType": "investigation",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 8,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 8,
+ "passengers": 0,
+ "door": "closed"
+ },
+ "primaryConflict": "empty_weight",
+ "explanation": "轿厢无人,但载重连续两次记录为一人。",
+ "visualState": "14_duplicate_subject",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "count"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_07",
+ "normal_shift_08"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "empty_weight_cam01",
+ "source": "cam01",
+ "conflictKey": "empty_weight",
+ "observation": "轿厢无人,但载重连续两次记录为一人。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "empty_weight_cam03",
+ "source": "cam03",
+ "conflictKey": "empty_weight",
+ "observation": "轿厢无人,但载重连续两次记录为一人。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "empty_weight_cam07",
+ "source": "cam07",
+ "conflictKey": "empty_weight",
+ "observation": "轿厢无人,但载重连续两次记录为一人。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "empty_weight_thermal",
+ "source": "thermal",
+ "conflictKey": "empty_weight",
+ "observation": "轿厢无人,但载重连续两次记录为一人。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "empty_weight_replay",
+ "source": "replay",
+ "conflictKey": "empty_weight",
+ "observation": "轿厢无人,但载重连续两次记录为一人。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "count_maintenance_counted",
+ "name": "维修员计数错误",
+ "category": "count",
+ "roundType": "identity",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 9,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 9,
+ "passengers": 2,
+ "door": "closed"
+ },
+ "primaryConflict": "maintenance_counted",
+ "explanation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "visualState": "14_duplicate_subject",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "count",
+ "identity"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_08",
+ "normal_shift_09"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "maintenance_counted_cam01",
+ "source": "cam01",
+ "conflictKey": "maintenance_counted",
+ "observation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "maintenance_counted_cam03",
+ "source": "cam03",
+ "conflictKey": "maintenance_counted",
+ "observation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "maintenance_counted_cam07",
+ "source": "cam07",
+ "conflictKey": "maintenance_counted",
+ "observation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "maintenance_counted_thermal",
+ "source": "thermal",
+ "conflictKey": "maintenance_counted",
+ "observation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "maintenance_counted_replay",
+ "source": "replay",
+ "conflictKey": "maintenance_counted",
+ "observation": "黄色胸牌维修员按协议应忽略,主控却计入人数。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "count_reflection_count",
+ "name": "倒影独立计数",
+ "category": "count",
+ "roundType": "investigation",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 10,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 10,
+ "passengers": 0,
+ "door": "closed"
+ },
+ "primaryConflict": "reflection_count",
+ "explanation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "visualState": "14_duplicate_subject",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "count"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_09",
+ "normal_shift_10"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "reflection_count_cam01",
+ "source": "cam01",
+ "conflictKey": "reflection_count",
+ "observation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "reflection_count_cam03",
+ "source": "cam03",
+ "conflictKey": "reflection_count",
+ "observation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "reflection_count_cam07",
+ "source": "cam07",
+ "conflictKey": "reflection_count",
+ "observation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "reflection_count_thermal",
+ "source": "thermal",
+ "conflictKey": "reflection_count",
+ "observation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "reflection_count_replay",
+ "source": "replay",
+ "conflictKey": "reflection_count",
+ "observation": "镜面中的人影动作与乘客不同步,人数传感器多计一人。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "count_exit_without_decrement",
+ "name": "离开后未减员",
+ "category": "count",
+ "roundType": "investigation",
+ "difficulty": 1,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 11,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 11,
+ "passengers": 2,
+ "door": "closed"
+ },
+ "primaryConflict": "exit_without_decrement",
+ "explanation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "visualState": "14_duplicate_subject",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "count"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam03",
+ "panel"
+ ],
+ "normalVariants": [
+ "normal_shift_10",
+ "normal_shift_01"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "exit_without_decrement_cam01",
+ "source": "cam01",
+ "conflictKey": "exit_without_decrement",
+ "observation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "exit_without_decrement_cam03",
+ "source": "cam03",
+ "conflictKey": "exit_without_decrement",
+ "observation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "exit_without_decrement_cam07",
+ "source": "cam07",
+ "conflictKey": "exit_without_decrement",
+ "observation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "exit_without_decrement_thermal",
+ "source": "thermal",
+ "conflictKey": "exit_without_decrement",
+ "observation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "exit_without_decrement_replay",
+ "source": "replay",
+ "conflictKey": "exit_without_decrement",
+ "observation": "CAM-03 显示乘客离开,主控人数仍未减少。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "space_floor_13",
+ "name": "不存在楼层",
+ "category": "space",
+ "roundType": "highRisk",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 13,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 13,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "floor_13",
+ "explanation": "楼层请求指向协议中不存在的 13 层。",
+ "visualState": "16_wrong_floor",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "space"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_01",
+ "normal_shift_02"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "floor_13_cam01",
+ "source": "cam01",
+ "conflictKey": "floor_13",
+ "observation": "楼层请求指向协议中不存在的 13 层。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "floor_13_cam03",
+ "source": "cam03",
+ "conflictKey": "floor_13",
+ "observation": "楼层请求指向协议中不存在的 13 层。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "floor_13_cam07",
+ "source": "cam07",
+ "conflictKey": "floor_13",
+ "observation": "楼层请求指向协议中不存在的 13 层。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "floor_13_thermal",
+ "source": "thermal",
+ "conflictKey": "floor_13",
+ "observation": "楼层请求指向协议中不存在的 13 层。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "floor_13_replay",
+ "source": "replay",
+ "conflictKey": "floor_13",
+ "observation": "楼层请求指向协议中不存在的 13 层。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "space_wrong_corridor",
+ "name": "错误走廊",
+ "category": "space",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 1,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 1,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "wrong_corridor",
+ "explanation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "visualState": "16_wrong_floor",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "space"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam03",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_02",
+ "normal_shift_03"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "wrong_corridor_cam01",
+ "source": "cam01",
+ "conflictKey": "wrong_corridor",
+ "observation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "wrong_corridor_cam03",
+ "source": "cam03",
+ "conflictKey": "wrong_corridor",
+ "observation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "wrong_corridor_cam07",
+ "source": "cam07",
+ "conflictKey": "wrong_corridor",
+ "observation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "wrong_corridor_thermal",
+ "source": "thermal",
+ "conflictKey": "wrong_corridor",
+ "observation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "wrong_corridor_replay",
+ "source": "replay",
+ "conflictKey": "wrong_corridor",
+ "observation": "CAM-03 显示的走廊结构与目标楼层档案不符。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "space_simultaneous_cameras",
+ "name": "双处出现",
+ "category": "space",
+ "roundType": "highRisk",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "simultaneous_cameras",
+ "explanation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "visualState": "16_wrong_floor",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "space"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "cam03"
+ ],
+ "normalVariants": [
+ "normal_shift_03",
+ "normal_shift_04"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "simultaneous_cameras_cam01",
+ "source": "cam01",
+ "conflictKey": "simultaneous_cameras",
+ "observation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "simultaneous_cameras_cam03",
+ "source": "cam03",
+ "conflictKey": "simultaneous_cameras",
+ "observation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "simultaneous_cameras_cam07",
+ "source": "cam07",
+ "conflictKey": "simultaneous_cameras",
+ "observation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "simultaneous_cameras_thermal",
+ "source": "thermal",
+ "conflictKey": "simultaneous_cameras",
+ "observation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "simultaneous_cameras_replay",
+ "source": "replay",
+ "conflictKey": "simultaneous_cameras",
+ "observation": "同一乘客同时出现在 CAM-01 与 CAM-03。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "space_shaft_entry",
+ "name": "井道提前进入",
+ "category": "space",
+ "roundType": "highRisk",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "shaft_entry",
+ "explanation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "visualState": "16_wrong_floor",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "space"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_04",
+ "normal_shift_05"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "shaft_entry_cam01",
+ "source": "cam01",
+ "conflictKey": "shaft_entry",
+ "observation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "shaft_entry_cam03",
+ "source": "cam03",
+ "conflictKey": "shaft_entry",
+ "observation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "shaft_entry_cam07",
+ "source": "cam07",
+ "conflictKey": "shaft_entry",
+ "observation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "shaft_entry_thermal",
+ "source": "thermal",
+ "conflictKey": "shaft_entry",
+ "observation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "shaft_entry_replay",
+ "source": "replay",
+ "conflictKey": "shaft_entry",
+ "observation": "CAM-07 记录到乘客在轿厢到达前进入井道。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "space_door_to_wall",
+ "name": "门后墙体",
+ "category": "space",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "door_to_wall",
+ "explanation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "visualState": "16_wrong_floor",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "space"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "cam03"
+ ],
+ "normalVariants": [
+ "normal_shift_05",
+ "normal_shift_06"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "door_to_wall_cam01",
+ "source": "cam01",
+ "conflictKey": "door_to_wall",
+ "observation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "door_to_wall_cam03",
+ "source": "cam03",
+ "conflictKey": "door_to_wall",
+ "observation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "door_to_wall_cam07",
+ "source": "cam07",
+ "conflictKey": "door_to_wall",
+ "observation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "door_to_wall_thermal",
+ "source": "thermal",
+ "conflictKey": "door_to_wall",
+ "observation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "door_to_wall_replay",
+ "source": "replay",
+ "conflictKey": "door_to_wall",
+ "observation": "开门后出现封闭墙面,而主控仍报告楼层走廊。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "time_motion_loop",
+ "name": "动作循环",
+ "category": "time",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "motion_loop",
+ "explanation": "三秒回放显示乘客动作逐帧完全重复。",
+ "visualState": "11_camera_glitch",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "time"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_06",
+ "normal_shift_07"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "motion_loop_cam01",
+ "source": "cam01",
+ "conflictKey": "motion_loop",
+ "observation": "三秒回放显示乘客动作逐帧完全重复。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "motion_loop_cam03",
+ "source": "cam03",
+ "conflictKey": "motion_loop",
+ "observation": "三秒回放显示乘客动作逐帧完全重复。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "motion_loop_cam07",
+ "source": "cam07",
+ "conflictKey": "motion_loop",
+ "observation": "三秒回放显示乘客动作逐帧完全重复。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "motion_loop_thermal",
+ "source": "thermal",
+ "conflictKey": "motion_loop",
+ "observation": "三秒回放显示乘客动作逐帧完全重复。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "motion_loop_replay",
+ "source": "replay",
+ "conflictKey": "motion_loop",
+ "observation": "三秒回放显示乘客动作逐帧完全重复。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "time_clock_stall",
+ "name": "时间停止",
+ "category": "time",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "clock_stall",
+ "explanation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "visualState": "11_camera_glitch",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "time"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "panel"
+ ],
+ "normalVariants": [
+ "normal_shift_07",
+ "normal_shift_08"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "clock_stall_cam01",
+ "source": "cam01",
+ "conflictKey": "clock_stall",
+ "observation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "clock_stall_cam03",
+ "source": "cam03",
+ "conflictKey": "clock_stall",
+ "observation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "clock_stall_cam07",
+ "source": "cam07",
+ "conflictKey": "clock_stall",
+ "observation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "clock_stall_thermal",
+ "source": "thermal",
+ "conflictKey": "clock_stall",
+ "observation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "clock_stall_replay",
+ "source": "replay",
+ "conflictKey": "clock_stall",
+ "observation": "CAM-07 时间码停止,但主控时钟继续前进。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "time_early_arrival",
+ "name": "提前到达",
+ "category": "time",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "early_arrival",
+ "explanation": "井道记录显示轿厢在调度命令前已经到站。",
+ "visualState": "11_camera_glitch",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "time"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_08",
+ "normal_shift_09"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "early_arrival_cam01",
+ "source": "cam01",
+ "conflictKey": "early_arrival",
+ "observation": "井道记录显示轿厢在调度命令前已经到站。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "early_arrival_cam03",
+ "source": "cam03",
+ "conflictKey": "early_arrival",
+ "observation": "井道记录显示轿厢在调度命令前已经到站。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "early_arrival_cam07",
+ "source": "cam07",
+ "conflictKey": "early_arrival",
+ "observation": "井道记录显示轿厢在调度命令前已经到站。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "early_arrival_thermal",
+ "source": "thermal",
+ "conflictKey": "early_arrival",
+ "observation": "井道记录显示轿厢在调度命令前已经到站。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "early_arrival_replay",
+ "source": "replay",
+ "conflictKey": "early_arrival",
+ "observation": "井道记录显示轿厢在调度命令前已经到站。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "time_delay_overrun",
+ "name": "延迟超限",
+ "category": "time",
+ "roundType": "investigation",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 8,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 8,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "delay_overrun",
+ "explanation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "visualState": "11_camera_glitch",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "time"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_09",
+ "normal_shift_10"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "delay_overrun_cam01",
+ "source": "cam01",
+ "conflictKey": "delay_overrun",
+ "observation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "delay_overrun_cam03",
+ "source": "cam03",
+ "conflictKey": "delay_overrun",
+ "observation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "delay_overrun_cam07",
+ "source": "cam07",
+ "conflictKey": "delay_overrun",
+ "observation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "delay_overrun_thermal",
+ "source": "thermal",
+ "conflictKey": "delay_overrun",
+ "observation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "delay_overrun_replay",
+ "source": "replay",
+ "conflictKey": "delay_overrun",
+ "observation": "CAM-07 延迟超过协议允许的固定两秒。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "time_future_frame",
+ "name": "未来帧",
+ "category": "time",
+ "roundType": "highRisk",
+ "difficulty": 2,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 9,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 9,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "future_frame",
+ "explanation": "回放中出现三秒后才发生的开门动作。",
+ "visualState": "11_camera_glitch",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "time"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_10",
+ "normal_shift_01"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "future_frame_cam01",
+ "source": "cam01",
+ "conflictKey": "future_frame",
+ "observation": "回放中出现三秒后才发生的开门动作。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "future_frame_cam03",
+ "source": "cam03",
+ "conflictKey": "future_frame",
+ "observation": "回放中出现三秒后才发生的开门动作。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "future_frame_cam07",
+ "source": "cam07",
+ "conflictKey": "future_frame",
+ "observation": "回放中出现三秒后才发生的开门动作。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "future_frame_thermal",
+ "source": "thermal",
+ "conflictKey": "future_frame",
+ "observation": "回放中出现三秒后才发生的开门动作。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "future_frame_replay",
+ "source": "replay",
+ "conflictKey": "future_frame",
+ "observation": "回放中出现三秒后才发生的开门动作。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "device_door_state",
+ "name": "门状态冲突",
+ "category": "device",
+ "roundType": "quick",
+ "difficulty": 3,
+ "duration": 8,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 10,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 10,
+ "passengers": 1,
+ "door": "open"
+ },
+ "primaryConflict": "door_state",
+ "explanation": "画面中门已开启,主控仍报告关闭。",
+ "visualState": "09_door_jammed",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "device"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "panel"
+ ],
+ "normalVariants": [
+ "normal_shift_01",
+ "normal_shift_02"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "door_state_cam01",
+ "source": "cam01",
+ "conflictKey": "door_state",
+ "observation": "画面中门已开启,主控仍报告关闭。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "door_state_cam03",
+ "source": "cam03",
+ "conflictKey": "door_state",
+ "observation": "画面中门已开启,主控仍报告关闭。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "door_state_cam07",
+ "source": "cam07",
+ "conflictKey": "door_state",
+ "observation": "画面中门已开启,主控仍报告关闭。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "door_state_thermal",
+ "source": "thermal",
+ "conflictKey": "door_state",
+ "observation": "画面中门已开启,主控仍报告关闭。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "door_state_replay",
+ "source": "replay",
+ "conflictKey": "door_state",
+ "observation": "画面中门已开启,主控仍报告关闭。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "device_floor_sensor",
+ "name": "楼层传感错误",
+ "category": "device",
+ "roundType": "quick",
+ "difficulty": 3,
+ "duration": 8,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 11,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 11,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "floor_sensor",
+ "explanation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "visualState": "09_door_jammed",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "device"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam03",
+ "panel"
+ ],
+ "normalVariants": [
+ "normal_shift_02",
+ "normal_shift_03"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "floor_sensor_cam01",
+ "source": "cam01",
+ "conflictKey": "floor_sensor",
+ "observation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "floor_sensor_cam03",
+ "source": "cam03",
+ "conflictKey": "floor_sensor",
+ "observation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "contradicts": true
+ }
+ ],
+ "cam07": [
+ {
+ "id": "floor_sensor_cam07",
+ "source": "cam07",
+ "conflictKey": "floor_sensor",
+ "observation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "floor_sensor_thermal",
+ "source": "thermal",
+ "conflictKey": "floor_sensor",
+ "observation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "floor_sensor_replay",
+ "source": "replay",
+ "conflictKey": "floor_sensor",
+ "observation": "CAM-03 楼层标识与主控楼层传感器不一致。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "device_camera_substitution",
+ "name": "画面被替换",
+ "category": "device",
+ "roundType": "highRisk",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 12,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 12,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "camera_substitution",
+ "explanation": "CAM-07 时间码变化但画面像素完全不变。",
+ "visualState": "09_door_jammed",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "device"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam07",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_03",
+ "normal_shift_04"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "camera_substitution_cam01",
+ "source": "cam01",
+ "conflictKey": "camera_substitution",
+ "observation": "CAM-07 时间码变化但画面像素完全不变。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "camera_substitution_cam03",
+ "source": "cam03",
+ "conflictKey": "camera_substitution",
+ "observation": "CAM-07 时间码变化但画面像素完全不变。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "camera_substitution_cam07",
+ "source": "cam07",
+ "conflictKey": "camera_substitution",
+ "observation": "CAM-07 时间码变化但画面像素完全不变。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "camera_substitution_thermal",
+ "source": "thermal",
+ "conflictKey": "camera_substitution",
+ "observation": "CAM-07 时间码变化但画面像素完全不变。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "camera_substitution_replay",
+ "source": "replay",
+ "conflictKey": "camera_substitution",
+ "observation": "CAM-07 时间码变化但画面像素完全不变。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "device_thermal_ghost",
+ "name": "虚假热源",
+ "category": "device",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 1,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 1,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "thermal_ghost",
+ "explanation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "visualState": "09_door_jammed",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "device"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "thermal",
+ "cam01"
+ ],
+ "normalVariants": [
+ "normal_shift_04",
+ "normal_shift_05"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "thermal_ghost_cam01",
+ "source": "cam01",
+ "conflictKey": "thermal_ghost",
+ "observation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "thermal_ghost_cam03",
+ "source": "cam03",
+ "conflictKey": "thermal_ghost",
+ "observation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "thermal_ghost_cam07",
+ "source": "cam07",
+ "conflictKey": "thermal_ghost",
+ "observation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "thermal_ghost_thermal",
+ "source": "thermal",
+ "conflictKey": "thermal_ghost",
+ "observation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "contradicts": true
+ },
+ "replay": {
+ "id": "thermal_ghost_replay",
+ "source": "replay",
+ "conflictKey": "thermal_ghost",
+ "observation": "空轿厢出现移动热源,三台摄像头均无人。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "device_door_cycle",
+ "name": "门循环超限",
+ "category": "device",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "door_cycle",
+ "explanation": "维护状态下门循环超过协议允许的一次。",
+ "visualState": "09_door_jammed",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "device"
+ ],
+ "protocolDependent": true,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "protocol"
+ ],
+ "normalVariants": [
+ "normal_shift_05",
+ "normal_shift_06"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "door_cycle_cam01",
+ "source": "cam01",
+ "conflictKey": "door_cycle",
+ "observation": "维护状态下门循环超过协议允许的一次。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "door_cycle_cam03",
+ "source": "cam03",
+ "conflictKey": "door_cycle",
+ "observation": "维护状态下门循环超过协议允许的一次。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "door_cycle_cam07",
+ "source": "cam07",
+ "conflictKey": "door_cycle",
+ "observation": "维护状态下门循环超过协议允许的一次。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "door_cycle_thermal",
+ "source": "thermal",
+ "conflictKey": "door_cycle",
+ "observation": "维护状态下门循环超过协议允许的一次。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "door_cycle_replay",
+ "source": "replay",
+ "conflictKey": "door_cycle",
+ "observation": "维护状态下门循环超过协议允许的一次。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "dynamic_instant_shift",
+ "name": "人物瞬移",
+ "category": "dynamic",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "instant_shift",
+ "explanation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "dynamic"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_06",
+ "normal_shift_07"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "instant_shift_cam01",
+ "source": "cam01",
+ "conflictKey": "instant_shift",
+ "observation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "instant_shift_cam03",
+ "source": "cam03",
+ "conflictKey": "instant_shift",
+ "observation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "instant_shift_cam07",
+ "source": "cam07",
+ "conflictKey": "instant_shift",
+ "observation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "instant_shift_thermal",
+ "source": "thermal",
+ "conflictKey": "instant_shift",
+ "observation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "instant_shift_replay",
+ "source": "replay",
+ "conflictKey": "instant_shift",
+ "observation": "乘客在相邻帧从轿厢左侧瞬移到门外。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "dynamic_delayed_shadow",
+ "name": "影子延迟",
+ "category": "dynamic",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 4,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "delayed_shadow",
+ "explanation": "乘客停止后影子仍继续移动两秒。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "dynamic"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_07",
+ "normal_shift_08"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "delayed_shadow_cam01",
+ "source": "cam01",
+ "conflictKey": "delayed_shadow",
+ "observation": "乘客停止后影子仍继续移动两秒。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "delayed_shadow_cam03",
+ "source": "cam03",
+ "conflictKey": "delayed_shadow",
+ "observation": "乘客停止后影子仍继续移动两秒。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "delayed_shadow_cam07",
+ "source": "cam07",
+ "conflictKey": "delayed_shadow",
+ "observation": "乘客停止后影子仍继续移动两秒。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "delayed_shadow_thermal",
+ "source": "thermal",
+ "conflictKey": "delayed_shadow",
+ "observation": "乘客停止后影子仍继续移动两秒。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "delayed_shadow_replay",
+ "source": "replay",
+ "conflictKey": "delayed_shadow",
+ "observation": "乘客停止后影子仍继续移动两秒。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "dynamic_reverse_walk",
+ "name": "逆向动作",
+ "category": "dynamic",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "reverse_walk",
+ "explanation": "乘客向前行走但位置持续向后移动。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "dynamic"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_08",
+ "normal_shift_09"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "reverse_walk_cam01",
+ "source": "cam01",
+ "conflictKey": "reverse_walk",
+ "observation": "乘客向前行走但位置持续向后移动。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "reverse_walk_cam03",
+ "source": "cam03",
+ "conflictKey": "reverse_walk",
+ "observation": "乘客向前行走但位置持续向后移动。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "reverse_walk_cam07",
+ "source": "cam07",
+ "conflictKey": "reverse_walk",
+ "observation": "乘客向前行走但位置持续向后移动。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "reverse_walk_thermal",
+ "source": "thermal",
+ "conflictKey": "reverse_walk",
+ "observation": "乘客向前行走但位置持续向后移动。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "reverse_walk_replay",
+ "source": "replay",
+ "conflictKey": "reverse_walk",
+ "observation": "乘客向前行走但位置持续向后移动。",
+ "contradicts": true
+ }
+ }
+ },
+ {
+ "id": "dynamic_frozen_passenger",
+ "name": "局部静止",
+ "category": "dynamic",
+ "roundType": "investigation",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "frozen_passenger",
+ "explanation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown",
+ "highRisk": false,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "dynamic"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 6,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "cam07"
+ ],
+ "normalVariants": [
+ "normal_shift_09",
+ "normal_shift_10"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "frozen_passenger_cam01",
+ "source": "cam01",
+ "conflictKey": "frozen_passenger",
+ "observation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "frozen_passenger_cam03",
+ "source": "cam03",
+ "conflictKey": "frozen_passenger",
+ "observation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "frozen_passenger_cam07",
+ "source": "cam07",
+ "conflictKey": "frozen_passenger",
+ "observation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "contradicts": true
+ }
+ ]
+ },
+ "thermal": {
+ "id": "frozen_passenger_thermal",
+ "source": "thermal",
+ "conflictKey": "frozen_passenger",
+ "observation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "frozen_passenger_replay",
+ "source": "replay",
+ "conflictKey": "frozen_passenger",
+ "observation": "轿厢震动时乘客轮廓保持像素级静止。",
+ "contradicts": false
+ }
+ }
+ },
+ {
+ "id": "dynamic_door_crossing",
+ "name": "穿门而过",
+ "category": "dynamic",
+ "roundType": "highRisk",
+ "difficulty": 3,
+ "duration": 14,
+ "decision": "anomaly",
+ "screenData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "primaryConflict": "door_crossing",
+ "explanation": "门关闭期间人物轮廓穿过实体门板。",
+ "visualState": "13_entity_near",
+ "audioCue": null,
+ "resolutionAction": "lockdown_floor",
+ "highRisk": true,
+ "availableTools": [
+ "camera",
+ "thermal",
+ "replay",
+ "protocol"
+ ],
+ "protocolTags": [
+ "dynamic"
+ ],
+ "protocolDependent": false,
+ "contaminationEffects": {
+ "onMiss": 12,
+ "onCorrect": -2
+ },
+ "silentEvidence": [
+ "cam01",
+ "replay"
+ ],
+ "normalVariants": [
+ "normal_shift_10",
+ "normal_shift_01"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "door_crossing_cam01",
+ "source": "cam01",
+ "conflictKey": "door_crossing",
+ "observation": "门关闭期间人物轮廓穿过实体门板。",
+ "contradicts": true
+ }
+ ],
+ "cam03": [
+ {
+ "id": "door_crossing_cam03",
+ "source": "cam03",
+ "conflictKey": "door_crossing",
+ "observation": "门关闭期间人物轮廓穿过实体门板。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "door_crossing_cam07",
+ "source": "cam07",
+ "conflictKey": "door_crossing",
+ "observation": "门关闭期间人物轮廓穿过实体门板。",
+ "contradicts": false
+ }
+ ]
+ },
+ "thermal": {
+ "id": "door_crossing_thermal",
+ "source": "thermal",
+ "conflictKey": "door_crossing",
+ "observation": "门关闭期间人物轮廓穿过实体门板。",
+ "contradicts": false
+ },
+ "replay": {
+ "id": "door_crossing_replay",
+ "source": "replay",
+ "conflictKey": "door_crossing",
+ "observation": "门关闭期间人物轮廓穿过实体门板。",
+ "contradicts": true
+ }
+ }
+ }
+]
diff --git a/src/content/endings.json b/src/content/endings.json
new file mode 100644
index 0000000..1524d12
--- /dev/null
+++ b/src/content/endings.json
@@ -0,0 +1,37 @@
+[
+ {
+ "id": "camera_taken",
+ "name": "替换信号",
+ "priority": 5,
+ "conditions": { "requiredFlag": "camera_chain_compromised" },
+ "summary": "你完成了值守,但 CAM-07 留下的已不是今晚的画面。"
+ },
+ {
+ "id": "floor_consumed",
+ "name": "第十三层",
+ "priority": 5,
+ "conditions": { "requiredFlag": "floor_chain_compromised" },
+ "summary": "主控恢复正常,楼层表却从此多出一个无法删除的编号。"
+ },
+ {
+ "id": "contaminated_survivor",
+ "name": "带回来的夜班",
+ "priority": 3,
+ "conditions": { "minContamination": 76 },
+ "summary": "你活过了夜班,但系统污染已经跟随档案进入下一次值守。"
+ },
+ {
+ "id": "clean_shift",
+ "name": "清醒交班",
+ "priority": 1,
+ "conditions": { "maxContamination": 25, "minAccuracy": 0.8 },
+ "summary": "每次决定都有证据支撑。晨班接管时,所有摄像头仍可信。"
+ },
+ {
+ "id": "uncertain_shift",
+ "name": "未决记录",
+ "priority": 0,
+ "conditions": {},
+ "summary": "你完成了交班,但有几段记录无法证明究竟发生过什么。"
+ }
+]
diff --git a/src/content/eventChains.json b/src/content/eventChains.json
new file mode 100644
index 0000000..fffbb93
--- /dev/null
+++ b/src/content/eventChains.json
@@ -0,0 +1,122 @@
+[
+ {
+ "id": "duplicate_passenger",
+ "initialFlags": [],
+ "steps": [
+ {
+ "id": "first_visit",
+ "roundType": "identity",
+ "contentId": "normal_shift_02",
+ "trigger": "first_duplicate_candidate",
+ "onWrongFlags": [
+ "trusted_duplicate"
+ ]
+ },
+ {
+ "id": "repeated_motion",
+ "roundType": "investigation",
+ "contentId": "person_duplicate_face",
+ "trigger": "trusted_duplicate_or_next_shift",
+ "onWrongFlags": [
+ "motion_ignored"
+ ]
+ },
+ {
+ "id": "simultaneous_presence",
+ "roundType": "highRisk",
+ "contentId": "space_simultaneous_cameras",
+ "trigger": "second_duplicate_seen",
+ "onWrongFlags": [
+ "chain_compromised"
+ ]
+ }
+ ],
+ "consequences": [
+ {
+ "flag": "chain_compromised",
+ "contaminationDelta": 18,
+ "nextShiftModifier": "duplicate_feed"
+ }
+ ]
+ },
+ {
+ "id": "nonexistent_floor",
+ "initialFlags": [],
+ "steps": [
+ {
+ "id": "floor_flash",
+ "roundType": "quick",
+ "contentId": "device_floor_sensor",
+ "trigger": "floor_display_flash",
+ "onWrongFlags": [
+ "floor_flash_ignored"
+ ]
+ },
+ {
+ "id": "passenger_request",
+ "roundType": "identity",
+ "contentId": "person_unknown_identity",
+ "trigger": "floor_flash_ignored_or_next_shift",
+ "onWrongFlags": [
+ "invalid_request_allowed"
+ ]
+ },
+ {
+ "id": "impossible_space",
+ "roundType": "highRisk",
+ "contentId": "space_floor_13",
+ "trigger": "invalid_request_allowed_or_escalation",
+ "onWrongFlags": [
+ "floor_chain_compromised"
+ ]
+ }
+ ],
+ "consequences": [
+ {
+ "flag": "floor_chain_compromised",
+ "contaminationDelta": 22,
+ "nextShiftModifier": "floor_13_bleed"
+ }
+ ]
+ },
+ {
+ "id": "camera_replacement",
+ "initialFlags": [],
+ "steps": [
+ {
+ "id": "cam07_delay",
+ "roundType": "investigation",
+ "contentId": "time_delay_overrun",
+ "trigger": "cam07_delay",
+ "onWrongFlags": [
+ "delay_accepted"
+ ]
+ },
+ {
+ "id": "time_stops",
+ "roundType": "investigation",
+ "contentId": "time_clock_stall",
+ "trigger": "delay_accepted_or_next_shift",
+ "onWrongFlags": [
+ "clock_stop_ignored"
+ ]
+ },
+ {
+ "id": "feed_replaced",
+ "roundType": "highRisk",
+ "contentId": "device_camera_substitution",
+ "trigger": "clock_stop_ignored_or_escalation",
+ "onWrongFlags": [
+ "camera_chain_compromised"
+ ]
+ }
+ ],
+ "consequences": [
+ {
+ "flag": "camera_chain_compromised",
+ "contaminationDelta": 20,
+ "nextShiftModifier": "unreliable_cam07"
+ }
+ ]
+ }
+]
diff --git a/src/content/normalShifts.json b/src/content/normalShifts.json
new file mode 100644
index 0000000..5c6cd06
--- /dev/null
+++ b/src/content/normalShifts.json
@@ -0,0 +1,478 @@
+[
+ {
+ "id": "normal_shift_01",
+ "roundType": "investigation",
+ "screenData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 2,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "resident_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_01_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_01_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_01_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_02",
+ "roundType": "identity",
+ "screenData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 3,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "worker_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_02_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_02_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_02_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_03",
+ "roundType": "quick",
+ "screenData": {
+ "floor": 4,
+ "passengers": 0,
+ "door": "open"
+ },
+ "panelData": {
+ "floor": 4,
+ "passengers": 0,
+ "door": "open"
+ },
+ "protocolTags": [
+ "device"
+ ],
+ "passengerIds": [],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_03_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_03_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_03_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_04",
+ "roundType": "investigation",
+ "screenData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 5,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "cleaner_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_04_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_04_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_04_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_05",
+ "roundType": "identity",
+ "screenData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 6,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "security_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_05_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_05_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_05_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_06",
+ "roundType": "quick",
+ "screenData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "open"
+ },
+ "panelData": {
+ "floor": 7,
+ "passengers": 1,
+ "door": "open"
+ },
+ "protocolTags": [
+ "device"
+ ],
+ "passengerIds": [
+ "resident_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_06_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_06_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_06_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_07",
+ "roundType": "investigation",
+ "screenData": {
+ "floor": 8,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 8,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "worker_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_07_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_07_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_07_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_08",
+ "roundType": "identity",
+ "screenData": {
+ "floor": 9,
+ "passengers": 0,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 9,
+ "passengers": 0,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_08_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_08_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_08_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_09",
+ "roundType": "quick",
+ "screenData": {
+ "floor": 10,
+ "passengers": 1,
+ "door": "open"
+ },
+ "panelData": {
+ "floor": 10,
+ "passengers": 1,
+ "door": "open"
+ },
+ "protocolTags": [
+ "device"
+ ],
+ "passengerIds": [
+ "cleaner_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_09_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_09_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_09_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ },
+ {
+ "id": "normal_shift_10",
+ "roundType": "investigation",
+ "screenData": {
+ "floor": 11,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "panelData": {
+ "floor": 11,
+ "passengers": 1,
+ "door": "closed"
+ },
+ "protocolTags": [
+ "personnel"
+ ],
+ "passengerIds": [
+ "security_001"
+ ],
+ "evidence": {
+ "cameras": {
+ "cam01": [
+ {
+ "id": "normal_shift_10_cam01",
+ "source": "cam01",
+ "observation": "画面与主控一致。",
+ "contradicts": false
+ }
+ ],
+ "cam03": [
+ {
+ "id": "normal_shift_10_cam03",
+ "source": "cam03",
+ "observation": "进出记录一致。",
+ "contradicts": false
+ }
+ ],
+ "cam07": [
+ {
+ "id": "normal_shift_10_cam07",
+ "source": "cam07",
+ "observation": "井道时序正常。",
+ "contradicts": false
+ }
+ ]
+ }
+ }
+ }
+]
diff --git a/src/content/passengers.json b/src/content/passengers.json
new file mode 100644
index 0000000..fec1e9b
--- /dev/null
+++ b/src/content/passengers.json
@@ -0,0 +1,84 @@
+[
+ {
+ "id": "worker_001",
+ "name": "张伟",
+ "role": "maintenance",
+ "badge": "yellow",
+ "allowedFloors": [
+ "B2",
+ "8"
+ ],
+ "countMode": "ignore",
+ "verificationPaths": [
+ "cam01",
+ "protocol"
+ ]
+ },
+ {
+ "id": "resident_001",
+ "name": "林岚",
+ "role": "resident",
+ "badge": "blue",
+ "allowedFloors": [
+ "3",
+ "6",
+ "9"
+ ],
+ "countMode": "normal",
+ "verificationPaths": [
+ "cam01",
+ "registry"
+ ]
+ },
+ {
+ "id": "courier_001",
+ "name": "陈杰",
+ "role": "courier",
+ "badge": "orange",
+ "allowedFloors": [
+ "1",
+ "2",
+ "3"
+ ],
+ "countMode": "normal",
+ "verificationPaths": [
+ "cam03",
+ "registry"
+ ]
+ },
+ {
+ "id": "cleaner_001",
+ "name": "赵梅",
+ "role": "cleaner",
+ "badge": "green",
+ "allowedFloors": [
+ "2",
+ "4",
+ "6",
+ "8",
+ "10",
+ "12"
+ ],
+ "countMode": "normal",
+ "verificationPaths": [
+ "cam01",
+ "protocol"
+ ]
+ },
+ {
+ "id": "security_001",
+ "name": "周启",
+ "role": "security",
+ "badge": "white",
+ "allowedFloors": [
+ "B2",
+ "1",
+ "13"
+ ],
+ "countMode": "normal",
+ "verificationPaths": [
+ "cam01",
+ "protocol"
+ ]
+ }
+]
diff --git a/src/content/protocols.json b/src/content/protocols.json
new file mode 100644
index 0000000..15422f9
--- /dev/null
+++ b/src/content/protocols.json
@@ -0,0 +1,56 @@
+[
+ {
+ "id": "floor_13_forbidden",
+ "category": "floor",
+ "text": "13 层不存在,任何前往 13 层的请求必须封锁。",
+ "protocolTags": ["floor"],
+ "condition": { "field": "floor", "operator": "equals", "value": 13 },
+ "decision": "lockdown",
+ "verificationPaths": ["protocol", "cam07"]
+ },
+ {
+ "id": "maintenance_badge_required",
+ "category": "identity",
+ "text": "维修人员不计入人数,但必须佩戴黄色胸牌。",
+ "protocolTags": ["identity", "personnel"],
+ "condition": { "field": "identity", "operator": "equals", "value": "maintenance_without_yellow_badge" },
+ "decision": "lockdown",
+ "verificationPaths": ["cam01", "thermal"]
+ },
+ {
+ "id": "cam07_delay_expected",
+ "category": "device",
+ "text": "CAM-07 固定延迟两秒,延迟本身不属于异常。",
+ "protocolTags": ["device"],
+ "condition": { "field": "cameraDelayMs", "operator": "lte", "value": 2000 },
+ "decision": "release",
+ "verificationPaths": ["cam01", "replay"]
+ },
+ {
+ "id": "cleaner_after_midnight",
+ "category": "time",
+ "text": "零点后清洁人员只能前往偶数楼层。",
+ "protocolTags": ["time", "personnel"],
+ "condition": { "field": "cleanerOddFloorAfterMidnight", "operator": "truthy" },
+ "decision": "lockdown",
+ "verificationPaths": ["protocol", "cam01"]
+ },
+ {
+ "id": "empty_car_weight_tolerance",
+ "category": "personnel",
+ "text": "空轿厢允许一次载重计数延迟,但第二次必须封锁。",
+ "protocolTags": ["personnel", "device"],
+ "condition": { "field": "emptyWeightDelayCount", "operator": "gte", "value": 2 },
+ "decision": "lockdown",
+ "verificationPaths": ["cam01", "replay"]
+ },
+ {
+ "id": "door_cycle_maintenance",
+ "category": "device",
+ "text": "维护灯亮起时允许一次门状态循环。",
+ "protocolTags": ["device"],
+ "condition": { "field": "maintenanceDoorCycles", "operator": "lte", "value": 1 },
+ "decision": "release",
+ "verificationPaths": ["cam01", "cam03"]
+ }
+]
diff --git a/src/debriefTimeline.js b/src/debriefTimeline.js
new file mode 100644
index 0000000..ad2ddcf
--- /dev/null
+++ b/src/debriefTimeline.js
@@ -0,0 +1,44 @@
+function timelineItem(type, entry) {
+ return { type, ...entry, sequence: Number(entry.sequence || 0) };
+}
+
+export function buildDebriefTimeline({ decisions = [], eventHistory = [], contaminationHistory = [] } = {}) {
+ const timeline = [
+ ...decisions.map(entry => timelineItem('decision', entry)),
+ ...eventHistory.map(entry => timelineItem('event-chain', entry)),
+ ...contaminationHistory.map(entry => timelineItem('contamination', entry)),
+ ].sort((a, b) => a.sequence - b.sequence);
+ const correct = decisions.filter(item => item.correct).length;
+ const wrong = decisions.length - correct;
+ const peakContamination = contaminationHistory.reduce(
+ (peak, item) => Math.max(peak, Number(item.value || 0)),
+ 0,
+ );
+ return {
+ timeline,
+ summary: {
+ decisions: decisions.length,
+ correct,
+ wrong,
+ accuracy: decisions.length ? correct / decisions.length : 0,
+ peakContamination,
+ eventStages: eventHistory.length,
+ },
+ };
+}
+
+function matchesEnding(ending, result) {
+ const condition = ending.condition || ending.conditions || {};
+ if (condition.requiredFlag && !(result.flags || []).includes(condition.requiredFlag)) return false;
+ if (condition.minContamination != null && result.contamination < condition.minContamination) return false;
+ if (condition.maxContamination != null && result.contamination > condition.maxContamination) return false;
+ if (condition.minAccuracy != null && result.accuracy < condition.minAccuracy) return false;
+ if (condition.maxAccuracy != null && result.accuracy > condition.maxAccuracy) return false;
+ return true;
+}
+
+export function selectNightEnding(endings = [], result = {}) {
+ return [...endings]
+ .filter(ending => matchesEnding(ending, result))
+ .sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0) || a.id.localeCompare(b.id))[0] ?? null;
+}
diff --git a/src/eventChainEngine.js b/src/eventChainEngine.js
new file mode 100644
index 0000000..e099e90
--- /dev/null
+++ b/src/eventChainEngine.js
@@ -0,0 +1,42 @@
+function cloneChainState(state) {
+ return {
+ chains: Object.fromEntries(Object.entries(state.chains || {}).map(([id, value]) => [id, { ...value }])),
+ flags: [...(state.flags || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+export function createEventChainState(chains = []) {
+ return {
+ chains: Object.fromEntries(chains.map(chain => [chain.id, { stepIndex: 0, completed: false }])),
+ flags: [...new Set(chains.flatMap(chain => chain.initialFlags || []))],
+ history: [],
+ };
+}
+
+export function getCurrentEventStep(state, chain) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return null;
+ return chain.steps?.[progress.stepIndex] ?? null;
+}
+
+export function advanceEventChain(state, chain, outcome = {}) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return { state, accepted: false, completed: Boolean(progress?.completed), consequences: [] };
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return { state, accepted: false, completed: true, consequences: [] };
+
+ const next = cloneChainState(state);
+ if (outcome.correct === false) {
+ next.flags.push(...(step.onWrongFlags || []));
+ next.flags = [...new Set(next.flags)];
+ }
+ const nextIndex = progress.stepIndex + 1;
+ const completed = nextIndex >= chain.steps.length;
+ next.chains[chain.id] = { stepIndex: nextIndex, completed };
+ next.history.push({ chainId: chain.id, stepId: step.id, correct: outcome.correct !== false });
+ const consequences = completed
+ ? (chain.consequences || []).filter(item => !item.flag || next.flags.includes(item.flag))
+ : [];
+ return { state: next, accepted: true, completed, consequences };
+}
diff --git a/src/evidenceEngine.js b/src/evidenceEngine.js
new file mode 100644
index 0000000..487cbe6
--- /dev/null
+++ b/src/evidenceEngine.js
@@ -0,0 +1,55 @@
+const CORE_FIELDS = Object.freeze(['floor', 'passengers', 'door']);
+const FIELD_LABELS = Object.freeze({ floor: '楼层', passengers: '人数', door: '门状态' });
+
+export function compareCoreEvidence(screenData = {}, panelData = {}) {
+ return CORE_FIELDS
+ .filter(field => screenData[field] !== panelData[field])
+ .map(field => ({ field, screen: screenData[field], panel: panelData[field] }));
+}
+
+export function evaluateEvidence({ screenData = {}, panelData = {}, protocolResult = null } = {}) {
+ const conflicts = compareCoreEvidence(screenData, panelData);
+ if (protocolResult?.violated) {
+ conflicts.push({ field: 'protocol', screen: protocolResult.observed, panel: protocolResult.expected });
+ }
+ const decision = conflicts.length ? 'lockdown' : 'release';
+ const explanation = conflicts.length
+ ? conflicts.map(item => `${FIELD_LABELS[item.field] || '协议'}不一致`).join(';')
+ : '画面与主控数据一致。';
+ return {
+ decision,
+ conflicts,
+ explanation,
+ presentationTone: 'neutral',
+ highlightConflictBeforeDecision: false,
+ };
+}
+
+export function evaluateInvestigationEvidence(discoveredEvidence = []) {
+ const contradictions = discoveredEvidence.filter(item => item?.contradicts && item?.conflictKey && item?.source);
+ const groups = new Map();
+ for (const evidence of contradictions) {
+ const sources = groups.get(evidence.conflictKey) || new Set();
+ sources.add(evidence.source);
+ groups.set(evidence.conflictKey, sources);
+ }
+ const corroborated = [...groups.entries()].filter(([, sources]) => sources.size >= 2);
+ const verificationPaths = [...new Set(
+ corroborated.flatMap(([, sources]) => [...sources]),
+ )].sort();
+ const ready = corroborated.length > 0;
+ return {
+ ready,
+ decision: ready ? 'lockdown' : null,
+ conflicts: corroborated.map(([conflictKey]) => conflictKey),
+ verificationPaths,
+ presentationTone: 'neutral',
+ };
+}
+
+export function isEvidenceJudgeableWithoutAudio(shift = {}) {
+ const conflicts = compareCoreEvidence(shift.screenData, shift.panelData);
+ const cameras = shift.evidence?.cameras || [];
+ const tools = shift.evidence?.tools || [];
+ return conflicts.length > 0 || cameras.length > 0 || tools.some(tool => tool !== 'audio');
+}
diff --git a/src/game.js b/src/game.js
index 17584be..81adf25 100644
--- a/src/game.js
+++ b/src/game.js
@@ -3,7 +3,7 @@ import { applyAnomaly, pickNextAnomaly } from './events.js';
import { summarizeFailure } from './feedback.js';
import { recordFailure, recordSuccessfulShift, reviveFromAd, saveSnapshot, tickState } from './state.js';
import CONFIG from './gameConfig.js';
-import { playClick, playSuccess, playFail, playAnomaly, playWarning, playCrash, playRevive, playRestart } from './audio.js';
+import { playClick, playSuccess, playFail, playAnomaly, playWarning, playCrash, playRevive, playRestart, setMusicState, pauseMusic, resumeMusic, stopMusic } from './audio.js';
import { t, actionLabel, getSkin, getAnomalies } from './skinManager.js';
import { createRewardedAd } from '../platform/platform.js';
import { getDecodedMonitorText, getDirectionLabel, getDomLabels, getDoorLabel } from './uiLabels.js';
@@ -101,6 +101,7 @@ let lastTone = 'normal';
let crashPlayed = false;
let fakeEndingTracked = false;
let runToken = 0;
+let musicStarted = false;
function analyticsPayload(extra = {}) {
return {
@@ -258,6 +259,10 @@ function renderActions(visual = deriveVisualState(state)) {
function render() {
const labels = getDomLabels();
const visual = deriveVisualState(state);
+ if (musicStarted) {
+ if (state.gameOver) stopMusic();
+ else setMusicState(state.activeAnomaly ? 'pressure' : 'calm');
+ }
renderActions(visual);
root.dataset.tone = visual.tone;
els.remaining.textContent = Math.ceil(state.remaining);
@@ -616,6 +621,8 @@ applyDomLabels();
refreshArchiveButton();
bindPress(els.startButton, () => {
playClick();
+ musicStarted = true;
+ setMusicState('calm');
ensureTimer();
});
bindPress(els.forceAnomaly, triggerAnomaly);
@@ -663,4 +670,11 @@ if (meta) {
}
render();
-window.addEventListener('beforeunload', () => window.clearInterval(timer));
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden) pauseMusic();
+ else if (musicStarted && !state.gameOver) resumeMusic();
+});
+window.addEventListener('beforeunload', () => {
+ window.clearInterval(timer);
+ stopMusic();
+});
diff --git a/src/highRiskResolution.js b/src/highRiskResolution.js
new file mode 100644
index 0000000..d07f3b3
--- /dev/null
+++ b/src/highRiskResolution.js
@@ -0,0 +1,35 @@
+const HIGH_RISK_ACTIONS = Object.freeze(['emergencyStop', 'restart', 'lockdownFloor']);
+
+function cloneHighRiskState(state) {
+ return {
+ ...state,
+ resolvedEvents: [...(state.resolvedEvents || [])],
+ nextShiftModifiers: [...(state.nextShiftModifiers || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+export function createHighRiskState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ resolvedEvents: [],
+ nextShiftModifiers: [],
+ history: [],
+ gameOver: false,
+ };
+}
+
+export function resolveHighRiskAction(state, event = {}, action) {
+ if (!HIGH_RISK_ACTIONS.includes(action)) return { state, accepted: false, correct: false, reason: 'unknown-action' };
+ const cost = Math.max(0, Number(event.costs?.[action] || 0));
+ if ((state.power ?? 0) < cost) return { state, accepted: false, correct: false, reason: 'insufficient-power' };
+
+ const next = cloneHighRiskState(state);
+ next.power -= cost;
+ const correct = (event.acceptedActions || []).includes(action);
+ if (correct && event.id && !next.resolvedEvents.includes(event.id)) next.resolvedEvents.push(event.id);
+ const modifier = correct ? event.successModifier : event.wrongModifiers?.[action];
+ if (modifier && !next.nextShiftModifiers.includes(modifier)) next.nextShiftModifiers.push(modifier);
+ next.history.push({ eventId: event.id ?? null, action, correct, powerCost: cost });
+ return { state: next, accepted: true, correct };
+}
diff --git a/src/identitySystem.js b/src/identitySystem.js
new file mode 100644
index 0000000..f40e64c
--- /dev/null
+++ b/src/identitySystem.js
@@ -0,0 +1,18 @@
+export function verifyPassengerIdentity(passenger = {}, observation = {}) {
+ const conflicts = [];
+ if (passenger.badge != null && observation.badge !== passenger.badge) conflicts.push('badge');
+ if (Array.isArray(passenger.allowedFloors)
+ && !passenger.allowedFloors.map(String).includes(String(observation.requestedFloor))) {
+ conflicts.push('floor');
+ }
+ return {
+ valid: conflicts.length === 0,
+ conflicts,
+ passengerId: passenger.id ?? null,
+ verificationPaths: ['cam01', 'protocol'],
+ };
+}
+
+export function countPassengersForPanel(passengers = []) {
+ return passengers.filter(passenger => passenger.countMode !== 'ignore').length;
+}
diff --git a/src/investigationTools.js b/src/investigationTools.js
new file mode 100644
index 0000000..6bf4288
--- /dev/null
+++ b/src/investigationTools.js
@@ -0,0 +1,64 @@
+const TOOL_CONFIG = Object.freeze({
+ thermal: Object.freeze({ uses: 2, powerCost: 8, evidenceKey: 'thermal' }),
+ replay: Object.freeze({ uses: 2, powerCost: 4, evidenceKey: 'replay' }),
+ protocol: Object.freeze({ uses: Number.POSITIVE_INFINITY, powerCost: 0, evidenceKey: 'protocol' }),
+});
+
+function cloneInvestigationState(state) {
+ return {
+ ...state,
+ tools: Object.fromEntries(
+ Object.entries(state.tools || {}).map(([id, tool]) => [id, { ...tool }]),
+ ),
+ discoveredEvidence: [...(state.discoveredEvidence || [])],
+ };
+}
+
+export function createInvestigationState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ activeCamera: 'cam01',
+ tools: Object.fromEntries(
+ Object.entries(TOOL_CONFIG).map(([id, config]) => [id, {
+ remaining: config.uses,
+ powerCost: config.powerCost,
+ }]),
+ ),
+ discoveredEvidence: [],
+ };
+}
+
+export function switchCamera(state, cameraId, shift = {}) {
+ if (!(shift.cameras || []).includes(cameraId)) {
+ return { state, accepted: false, reason: 'camera-unavailable', visibleEvidence: [] };
+ }
+ const next = cloneInvestigationState(state);
+ next.activeCamera = cameraId;
+ const visibleEvidence = [...(shift.evidence?.cameras?.[cameraId] || [])];
+ for (const evidence of visibleEvidence) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, visibleEvidence };
+}
+
+export function useInvestigationTool(state, toolId, shift = {}) {
+ const config = TOOL_CONFIG[toolId];
+ const currentTool = state?.tools?.[toolId];
+ if (!config || !currentTool) return { state, accepted: false, reason: 'unknown-tool' };
+ if (currentTool.remaining <= 0) return { state, accepted: false, reason: 'no-uses' };
+ if ((state.power ?? 0) < config.powerCost) return { state, accepted: false, reason: 'insufficient-power' };
+
+ const next = cloneInvestigationState(state);
+ next.power = Math.max(0, next.power - config.powerCost);
+ if (Number.isFinite(next.tools[toolId].remaining)) next.tools[toolId].remaining -= 1;
+ const discoveredEvidence = toolId === 'protocol'
+ ? [...(shift.activeProtocols || [])]
+ : shift.evidence?.[config.evidenceKey] ?? null;
+ const evidenceItems = Array.isArray(discoveredEvidence)
+ ? discoveredEvidence
+ : discoveredEvidence ? [discoveredEvidence] : [];
+ for (const evidence of evidenceItems) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, discoveredEvidence };
+}
diff --git a/src/nightInteraction.js b/src/nightInteraction.js
new file mode 100644
index 0000000..0ceb61f
--- /dev/null
+++ b/src/nightInteraction.js
@@ -0,0 +1,159 @@
+import { applyDecisionContamination } from './contamination.js';
+import { buildDebriefTimeline, selectNightEnding } from './debriefTimeline.js';
+import { createHighRiskState, resolveHighRiskAction } from './highRiskResolution.js';
+
+const CATEGORIES = Object.freeze(['person', 'quantity', 'space', 'time', 'device', 'dynamic']);
+const HIGH_RISK_COSTS = Object.freeze({ emergencyStop: 15, restart: 10, lockdownFloor: 12 });
+
+function clone(value) {
+ if (Array.isArray(value)) return value.map(clone);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
+ }
+ return value;
+}
+
+function appendDecision(state, decision) {
+ const next = clone(state);
+ const decisions = next.night.decisions || [];
+ const sequence = Number(next.night.timelineSequence || 0) + 1;
+ next.night.timelineSequence = sequence;
+ decisions.push({ sequence, ...decision });
+ next.night.decisions = decisions;
+ return next;
+}
+
+export function openProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = 'protocolQuery';
+ next.night.protocolQuery = clone(next.night.activeProtocols || []);
+ return next;
+}
+
+export function closeProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = null;
+ return next;
+}
+
+export function verifyCurrentIdentity(state) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity') {
+ return { state, accepted: false, reason: 'not-identity-round' };
+ }
+ const evidence = shift.evidence?.cameras?.cam01?.[0];
+ if (!evidence) return { state, accepted: false, reason: 'identity-evidence-missing' };
+ const next = clone(state);
+ const discovered = next.investigation.discoveredEvidence || [];
+ if (!discovered.some(item => item.id === evidence.id)) discovered.push(clone(evidence));
+ next.investigation.discoveredEvidence = discovered;
+ next.lastFeedback = `核验结果:${evidence.observation}`;
+ return { state: next, accepted: true, evidence: clone(evidence) };
+}
+
+export function resolveIdentityDecision(state, choice) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity' || !['release', 'reject'].includes(choice)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-identity-decision' };
+ }
+ const expected = shift.decision === 'anomaly' ? 'reject' : 'release';
+ const correct = choice === expected;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: `identity:${choice}`,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.roundType = 'quick';
+ next.lastFeedback = correct
+ ? (choice === 'release' ? '身份一致,准予放行' : '身份冲突,拒绝通行')
+ : '身份判断错误,污染已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct };
+}
+
+export function classifyCurrentShift(state, category) {
+ const shift = state?.night?.currentShift;
+ if (!shift || !CATEGORIES.includes(category)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-classification' };
+ }
+ const correct = shift.category === category;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'classification',
+ classification: category,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.overlay = null;
+ next.night.roundType = shift.roundType === 'highRisk' || shift.highRisk ? 'highRisk' : 'quick';
+ next.lastFeedback = correct ? `分类确认:${category}` : `分类不符:${category}`;
+ return { state: next, accepted: true, correct };
+}
+
+function acceptedHighRiskAction(shift) {
+ if (shift.resolutionAction === 'emergencyStop') return 'emergencyStop';
+ if (shift.resolutionAction === 'restart') return 'restart';
+ return 'lockdownFloor';
+}
+
+export function resolveCurrentHighRisk(state, action) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'highRisk') {
+ return { state, accepted: false, correct: false, reason: 'not-high-risk' };
+ }
+ const highRisk = createHighRiskState({ power: state.power });
+ highRisk.nextShiftModifiers = clone(state.night.nextShiftModifiers || []);
+ const result = resolveHighRiskAction(highRisk, {
+ id: shift.id,
+ acceptedActions: [acceptedHighRiskAction(shift)],
+ costs: HIGH_RISK_COSTS,
+ successModifier: `resolved:${shift.id}`,
+ wrongModifiers: {
+ emergencyStop: 'power-grid-stress',
+ restart: 'control-reliability-down',
+ lockdownFloor: 'camera-delay',
+ },
+ }, action);
+ if (!result.accepted) return { ...result, state };
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'highRisk',
+ action,
+ correct: result.correct,
+ });
+ next.power = result.state.power;
+ next.investigation.power = result.state.power;
+ next.night.nextShiftModifiers = result.state.nextShiftModifiers;
+ next.night.roundType = 'quick';
+ next.lastFeedback = result.correct ? '高危处置完成' : '处置失误已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct: result.correct };
+}
+
+export function createNightDebrief(state, endings = []) {
+ const report = buildDebriefTimeline({
+ decisions: state?.night?.decisions || [],
+ eventHistory: state?.night?.eventChainHistory || Object.values(state?.night?.eventChains || {}).flatMap(chain => chain.history || []),
+ contaminationHistory: state?.contamination?.history || [],
+ });
+ const eventChainFlags = state?.night?.eventChainFlags || [];
+ const nextShiftModifiers = state?.night?.nextShiftModifiers || [];
+ return {
+ ...report,
+ nextShiftModifiers: [...nextShiftModifiers],
+ ending: selectNightEnding(endings, {
+ flags: eventChainFlags,
+ contamination: Number(state?.contamination?.value || 0),
+ accuracy: report.summary.accuracy,
+ }),
+ };
+}
diff --git a/src/nightScheduler.js b/src/nightScheduler.js
new file mode 100644
index 0000000..f4810bd
--- /dev/null
+++ b/src/nightScheduler.js
@@ -0,0 +1,175 @@
+import { createInvestigationState } from './investigationTools.js';
+import { generateNightProtocols } from './protocolEngine.js';
+import { advanceEventChain, createEventChainState } from './eventChainEngine.js';
+import { changeContamination } from './contamination.js';
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function requireContentList(content, key) {
+ const list = content?.[key];
+ if (!Array.isArray(list) || list.length === 0) {
+ throw new Error(`V5 night scheduler requires non-empty ${key}`);
+ }
+ return list;
+}
+
+function pick(list, random) {
+ const value = Number(random());
+ const normalized = Number.isFinite(value) ? Math.max(0, Math.min(0.999999999999, value)) : 0;
+ return list[Math.floor(normalized * list.length)];
+}
+
+const NEXT_SHIFT_MODIFIER_VISUALS = Object.freeze({
+ duplicate_feed: '14_duplicate_subject',
+ floor_13_bleed: '16_wrong_floor',
+ unreliable_cam07: '10_signal_lost',
+});
+
+function installShift(state, shift, shiftKind, shiftIndex, activeProtocols, eventMeta = null) {
+ const next = clone(state);
+ const protocols = clone(activeProtocols);
+ const pendingModifiers = [...(next.night.nextShiftModifiers || [])];
+ const modifierVisualState = pendingModifiers
+ .map(modifier => NEXT_SHIFT_MODIFIER_VISUALS[modifier])
+ .find(Boolean);
+ next.night.activeProtocols = protocols;
+ next.night.currentShift = {
+ ...clone(shift),
+ ...(modifierVisualState ? { visualState: modifierVisualState } : {}),
+ ...(pendingModifiers.length ? { appliedModifiers: pendingModifiers } : {}),
+ shiftKind,
+ activeProtocols: clone(protocols),
+ ...(eventMeta ? {
+ eventChainId: eventMeta.chainId,
+ eventChainStep: eventMeta.stepId,
+ } : {}),
+ };
+ next.night.nextShiftModifiers = [];
+ next.night.roundType = shift.roundType || 'quick';
+ next.night.shiftIndex = shiftIndex;
+ next.investigation = createInvestigationState({ power: next.power });
+ return next;
+}
+
+function initialiseEventChains(state, content, random) {
+ if (!Array.isArray(content?.eventChains) || content.eventChains.length === 0) return state;
+ const next = clone(state);
+ const chainState = createEventChainState(content.eventChains);
+ next.night.eventChains = chainState.chains;
+ next.night.eventChainFlags = chainState.flags;
+ next.night.eventChainHistory = chainState.history;
+ next.night.activeEventChainId = pick(content.eventChains, random).id;
+ return next;
+}
+
+function getActiveChainStep(state, content) {
+ if (Number(state?.tutorialStep || 0) < 4) return null;
+ const chainId = state?.night?.activeEventChainId;
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ const progress = state?.night?.eventChains?.[chainId];
+ if (!chain || !progress || progress.completed) return null;
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return null;
+ const shift = [...(content.normalShifts || []), ...(content.anomalies || [])]
+ .find(item => item.id === step.contentId);
+ return shift ? { chain, progress, step, shift } : null;
+}
+
+export function createNightSchedule(state, content, options = {}) {
+ const normalShifts = requireContentList(content, 'normalShifts');
+ const anomalies = requireContentList(content, 'anomalies');
+ const protocols = requireContentList(content, 'protocols');
+ const random = options.random || Math.random;
+ const firstShift = pick(normalShifts, random);
+ const activeProtocols = generateNightProtocols({
+ protocols,
+ shifts: [...normalShifts, ...anomalies],
+ count: options.protocolCount ?? 3,
+ random,
+ });
+ const scheduled = installShift(state, firstShift, 'normal', 0, activeProtocols);
+ return initialiseEventChains(scheduled, content, random);
+}
+
+export function scheduleNextNightShift(state, content, options = {}) {
+ requireContentList(content, 'normalShifts');
+ requireContentList(content, 'anomalies');
+ const random = options.random || Math.random;
+ const nextIndex = Number(state?.night?.shiftIndex || 0) + 1;
+ const activeProtocols = state?.night?.activeProtocols?.length
+ ? state.night.activeProtocols
+ : requireContentList(content, 'protocols');
+ const chainStep = getActiveChainStep(state, content);
+ if (chainStep) {
+ return installShift(
+ state,
+ chainStep.shift,
+ chainStep.shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ nextIndex,
+ activeProtocols,
+ { chainId: chainStep.chain.id, stepId: chainStep.step.id },
+ );
+ }
+ const shiftKind = nextIndex % 2 === 0 ? 'normal' : 'anomaly';
+ const shift = pick(content[shiftKind === 'normal' ? 'normalShifts' : 'anomalies'], random);
+ return installShift(state, shift, shiftKind, nextIndex, activeProtocols);
+}
+
+export function advanceCurrentNightEventChain(state, content, outcome) {
+ if (Number(state?.tutorialStep || 0) < 4) return { state, advanced: false };
+ const chainId = state?.night?.activeEventChainId;
+ if (!chainId || !state?.night?.eventChains?.[chainId]) return { state, advanced: false };
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ if (!chain) return { state, advanced: false };
+ const chainState = {
+ chains: state.night.eventChains,
+ flags: state.night.eventChainFlags || [],
+ history: state.night.eventChainHistory || [],
+ };
+ const result = advanceEventChain(chainState, chain, outcome);
+ const next = clone(state);
+ let timelineSequence = Number(next.night.timelineSequence || 0);
+ const eventHistory = result.state.history.map(item => {
+ if (Number.isFinite(Number(item.sequence))) return item;
+ timelineSequence += 1;
+ return { ...item, sequence: timelineSequence };
+ });
+ next.night.timelineSequence = timelineSequence;
+ next.night.eventChains = {
+ ...next.night.eventChains,
+ [chainId]: {
+ ...result.state.chains[chainId],
+ history: eventHistory.filter(item => item.chainId === chainId),
+ },
+ };
+ next.night.eventChainFlags = result.state.flags;
+ next.night.eventChainHistory = eventHistory;
+ if (result.completed) next.night.activeEventChainId = null;
+ for (const consequence of result.consequences || []) {
+ if (Number(consequence.contaminationDelta || 0) !== 0) {
+ next.contamination = changeContamination(
+ next.contamination,
+ Number(consequence.contaminationDelta),
+ `event-chain:${chainId}`,
+ );
+ const history = next.contamination.history || [];
+ if (history.length > 0 && !Number.isFinite(Number(history.at(-1).sequence))) {
+ next.night.timelineSequence += 1;
+ history[history.length - 1] = {
+ ...history.at(-1),
+ sequence: next.night.timelineSequence,
+ };
+ next.contamination.history = history;
+ }
+ }
+ if (consequence.nextShiftModifier) {
+ next.night.nextShiftModifiers = [
+ ...(next.night.nextShiftModifiers || []),
+ consequence.nextShiftModifier,
+ ];
+ }
+ }
+ return { state: next, advanced: true, completed: Boolean(result.completed), result };
+}
diff --git a/src/protocolEngine.js b/src/protocolEngine.js
new file mode 100644
index 0000000..332e74d
--- /dev/null
+++ b/src/protocolEngine.js
@@ -0,0 +1,53 @@
+function compare(value, operator, expected) {
+ if (operator === 'equals') return value === expected;
+ if (operator === 'lte') return Number(value) <= Number(expected);
+ if (operator === 'gte') return Number(value) >= Number(expected);
+ if (operator === 'truthy') return Boolean(value);
+ return false;
+}
+
+export function protocolAppliesToShift(protocol, shift = {}) {
+ const tags = new Set(shift.protocolTags || []);
+ return (protocol.protocolTags || []).some(tag => tags.has(tag));
+}
+
+export function evaluateProtocolDecision(protocol, shift = {}) {
+ const condition = protocol?.condition || {};
+ const observed = shift.screenData?.[condition.field]
+ ?? shift.panelData?.[condition.field]
+ ?? shift.evidence?.[condition.field];
+ const matched = compare(observed, condition.operator, condition.value);
+ const violated = protocol?.decision === 'lockdown' ? matched : !matched;
+ return {
+ violated,
+ decision: violated ? 'lockdown' : 'release',
+ observed,
+ expected: condition.value,
+ verificationPaths: [...(protocol?.verificationPaths || [])],
+ };
+}
+
+export function evaluateNightProtocolSet(protocols = [], shift = {}) {
+ const applied = protocols.filter(protocol => protocolAppliesToShift(protocol, shift));
+ const results = applied.map(protocol => ({ protocol, result: evaluateProtocolDecision(protocol, shift) }));
+ const violated = results.filter(item => item.result.violated);
+ return {
+ decision: violated.length ? 'lockdown' : 'release',
+ appliedProtocolIds: applied.map(protocol => protocol.id),
+ violatedProtocolIds: violated.map(item => item.protocol.id),
+ verificationPaths: [...new Set(results.flatMap(item => item.result.verificationPaths))].sort(),
+ };
+}
+
+export function generateNightProtocols({ protocols = [], shifts = [], count = 2, random = Math.random } = {}) {
+ const target = Math.max(2, Math.min(3, Math.trunc(count || 2)));
+ const applicable = protocols.filter(protocol => shifts.some(shift => protocolAppliesToShift(protocol, shift)));
+ const selected = [];
+ if (applicable.length) selected.push(applicable[Math.floor(random() * applicable.length) % applicable.length]);
+ const remaining = protocols.filter(protocol => !selected.some(item => item.id === protocol.id));
+ while (selected.length < target && remaining.length) {
+ const index = Math.floor(random() * remaining.length) % remaining.length;
+ selected.push(remaining.splice(index, 1)[0]);
+ }
+ return selected;
+}
diff --git a/src/runtimeSession.js b/src/runtimeSession.js
index 00c20f2..1b4b741 100644
--- a/src/runtimeSession.js
+++ b/src/runtimeSession.js
@@ -1,15 +1,19 @@
import CONFIG from './gameConfig.js';
import { createInitialState } from './state.js';
+import { createNightSchedule } from './nightScheduler.js';
-export function createRuntimeSession() {
+export function createRuntimeSession(options = {}) {
+ const initialState = createInitialState();
return {
- state: createInitialState(),
+ state: options.content
+ ? createNightSchedule(initialState, options.content, options)
+ : initialState,
nextAnomalyAt: CONFIG.anomaly.firstTriggerAt,
};
}
-export function restartRuntimeSession(previousSession = null) {
- const session = createRuntimeSession();
+export function restartRuntimeSession(previousSession = null, options = {}) {
+ const session = createRuntimeSession(options);
const previous = previousSession?.state;
if (!previous) return session;
diff --git a/src/skins/elevator/skin.json b/src/skins/elevator/skin.json
index a907b33..bb1ef01 100644
--- a/src/skins/elevator/skin.json
+++ b/src/skins/elevator/skin.json
@@ -42,7 +42,7 @@
"logPanel": "系统日志",
"forceAnomaly": "触发异常测试",
"failureTitle": "系统崩溃",
- "failureEyebrow": "SYSTEM FAILURE",
+ "failureEyebrow": "系统故障",
"monitorSignalStable": "SYSTEM: STABLE",
"monitorSignalUnstable": "SYSTEM: UNSTABLE",
"monitorSignalCorrupted": "SYSTEM: CORRUPTED",
diff --git a/src/state.js b/src/state.js
index ee58c73..769434a 100644
--- a/src/state.js
+++ b/src/state.js
@@ -2,6 +2,23 @@ import CONFIG from './gameConfig.js';
import { createFeedbackLine } from './feedback.js';
import { findRollbackSnapshot } from './rollback.js';
import { t } from './skinManager.js';
+import { createContaminationState } from './contamination.js';
+import { createInvestigationState } from './investigationTools.js';
+
+function createNightState() {
+ return {
+ activeProtocols: [],
+ currentShift: null,
+ roundType: 'quick',
+ shiftIndex: 0,
+ decisions: [],
+ eventChains: {},
+ eventChainFlags: [],
+ eventChainHistory: [],
+ timelineSequence: 0,
+ nextShiftModifiers: [],
+ };
+}
export function createInitialState() {
const c = CONFIG.initial;
@@ -14,6 +31,9 @@ export function createInitialState() {
power: c.power,
stability: c.stability,
anomalyLevel: c.anomalyLevel,
+ contamination: createContaminationState(),
+ night: createNightState(),
+ investigation: createInvestigationState({ power: c.power }),
passengers: c.passengers,
gameOver: c.gameOver,
result: 'playing',
diff --git a/tests/androidWebview.test.js b/tests/androidWebview.test.js
index 53081dc..8381cfc 100644
--- a/tests/androidWebview.test.js
+++ b/tests/androidWebview.test.js
@@ -62,11 +62,14 @@ test('android WebView assets use bundled script instead of ES modules', () => {
24,
'Android WebView should include every imported desktop CCTV state',
);
+ const mobileCctvStates = readdirSync(resolve(assets, 'assets/abnormal_elevator_visual_assets/mobile_cctv_states'))
+ .filter(name => name.endsWith('.png'));
assert.equal(
- readdirSync(resolve(assets, 'assets/abnormal_elevator_visual_assets/mobile_cctv_states')).filter(name => name.endsWith('.png')).length,
- 24,
- 'Android WebView should include every imported mobile CCTV state',
+ mobileCctvStates.length,
+ 32,
+ 'Android WebView should include 24 V4 states and 8 V5 investigation scenes',
);
+ assert.ok(mobileCctvStates.includes('v5_02_investigation_mobile.png'));
assert.doesNotMatch(css, /games\/find-anomaly\/elevator-console\/assets\/abnormal_elevator_visual_assets/, 'Android WebView CSS should not point outside packaged assets');
assert.doesNotMatch(game, /\bSKIN_DATA\b/);
assert.doesNotMatch(game, /\b_getHiddenLog\b/);
diff --git a/tests/audio.test.js b/tests/audio.test.js
index 2d451be..16e770d 100644
--- a/tests/audio.test.js
+++ b/tests/audio.test.js
@@ -8,6 +8,10 @@ import {
playLayer,
setAudioMuted,
toggleAudioMuted,
+ setMusicState,
+ pauseMusic,
+ resumeMusic,
+ stopMusic,
} from '../src/audio.js';
test('audio layers separate game feedback categories', () => {
@@ -38,6 +42,32 @@ test('audio mute gate prevents layer playback and can be toggled', () => {
assert.equal(isAudioMuted(), false);
});
+test('H5 music starts after gesture and uses the source BGM loops', async () => {
+ const previousAudio = globalThis.Audio;
+ const instances = [];
+ globalThis.Audio = class {
+ constructor(src) { this.src = src; this.loop = false; this.volume = 1; this.paused = true; instances.push(this); }
+ play() { this.paused = false; return Promise.resolve(); }
+ pause() { this.paused = true; }
+ };
+ try {
+ setAudioMuted(false);
+ assert.equal(setMusicState('calm'), true);
+ assert.equal(instances.length, 1);
+ assert.match(instances[0].src, /bgm-night-shift-loop\.wav$/);
+ assert.equal(instances[0].loop, true);
+ assert.equal(setMusicState('pressure'), true);
+ assert.match(instances[0].src, /bgm-anomaly-pressure-loop\.wav$/);
+ pauseMusic();
+ assert.equal(resumeMusic(), true);
+ stopMusic();
+ assert.equal(instances[0].currentTime, 0);
+ } finally {
+ stopMusic();
+ globalThis.Audio = previousAudio;
+ }
+});
+
test('unknown audio layer is ignored instead of throwing', () => {
setAudioMuted(false);
assert.equal(getAudioLayer('missing'), null);
diff --git a/tests/build.test.js b/tests/build.test.js
index 99ca600..f4e7951 100644
--- a/tests/build.test.js
+++ b/tests/build.test.js
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import vm from 'node:vm';
const root = resolve(import.meta.dirname, '..');
const output = resolve(root, 'wechat-minigame', 'game.js');
@@ -24,6 +25,61 @@ test('wechat build output is deterministic across repeated runs', () => {
assert.equal(second, first);
});
+test('generated mini-game bundle injects deterministic V5 content containers', () => {
+ execFileSync(process.execPath, ['build.js', 'douyin'], { cwd: root, stdio: 'pipe' });
+ const first = readFileSync(douyinOutput, 'utf8');
+ execFileSync(process.execPath, ['build.js', 'douyin'], { cwd: root, stdio: 'pipe' });
+ const second = readFileSync(douyinOutput, 'utf8');
+
+ assert.equal(second, first, 'content injection must not make repeated builds drift');
+ assert.match(first, /\/\/ --- V5 content \(deterministic\) ---\nvar __V5_CONTENT__ = /);
+
+ const bundle = first.replace(
+ 'startMiniGame();\n})();',
+ 'globalThis.__bundleContent = __V5_CONTENT__;\n})();',
+ );
+ const sandbox = vm.createContext({ console, Promise, setTimeout, clearTimeout });
+ vm.runInContext(bundle, sandbox, { filename: 'douyin-minigame/game.js' });
+
+ assert.deepEqual(Object.keys(sandbox.__bundleContent), [
+ 'anomalies', 'endings', 'eventChains', 'normalShifts', 'passengers', 'protocols',
+ ]);
+ assert.equal(sandbox.__bundleContent.anomalies.length, 30);
+ assert.equal(sandbox.__bundleContent.normalShifts.length, 10);
+ assert.equal(sandbox.__bundleContent.passengers.length, 5);
+ assert.equal(sandbox.__bundleContent.protocols.length, 6);
+});
+
+test('generated mini-game bundle isolates module lexical scopes and remains executable', () => {
+ execFileSync(process.execPath, ['build.js', 'douyin'], { cwd: root, stdio: 'pipe' });
+ const rawBundle = readFileSync(douyinOutput, 'utf8');
+
+ assert.match(
+ rawBundle,
+ /\/\/ --- src\/protocolEngine\.js ---\nvar __exports_src_protocolEngine_js = \{\};\n\{\n/,
+ 'each source module should have its own lexical block so private names cannot collide',
+ );
+
+ const bundle = rawBundle.replace(
+ 'startMiniGame();\n})();',
+ 'globalThis.__bundleTest = { createInitialState, createInvestigationState, createRuntimeSession, content: __V5_CONTENT__ };\n})();',
+ );
+ const sandbox = vm.createContext({ console, Promise, setTimeout, clearTimeout });
+ vm.runInContext(bundle, sandbox, { filename: 'douyin-minigame/game.js' });
+
+ const state = sandbox.__bundleTest.createInitialState();
+ const investigation = sandbox.__bundleTest.createInvestigationState({ power: 64 });
+ assert.equal(state.result, 'playing');
+ assert.equal(investigation.power, 64);
+ const session = sandbox.__bundleTest.createRuntimeSession({
+ content: sandbox.__bundleTest.content,
+ random: () => 0,
+ });
+ assert.equal(session.state.night.activeProtocols.length, 3);
+ assert.equal(session.state.night.currentShift.id, 'normal_shift_01');
+ assert.equal(session.state.night.roundType, 'investigation');
+});
+
test('release config can inject private AppID and ad units into generated files', () => {
mkdirSync(tempDir, { recursive: true });
writeFileSync(tempReleaseConfig, JSON.stringify({
@@ -77,11 +133,15 @@ test('douyin build emits a tracked import-ready project with target-specific met
assert.equal(second, first);
assert.match(second, /MINIGAME - 抖音 小游戏构建/);
assert.equal(project.compileType, 'game');
- assert.equal(project.appid, 'touristappid');
+ const localReleaseConfigPath = resolve(root, 'release.config.json');
+ const expectedAppId = existsSync(localReleaseConfigPath)
+ ? JSON.parse(readFileSync(localReleaseConfigPath, 'utf8')).douyin?.appid || 'touristappid'
+ : 'touristappid';
+ assert.equal(project.appid, expectedAppId);
assert.equal(project.miniprogramRoot, './');
assert.equal(game.deviceOrientation, 'portrait');
assert.equal(game.showStatusBar, false);
- assert.deepEqual(game.subPackages, []);
+ assert.deepEqual(game.subPackages, [{ root: 'visual', name: 'v5-visual' }]);
for (const cue of ['click.wav', 'anomaly.wav', 'result.wav', 'boot.wav', 'release.wav', 'lockdown.wav', 'motor.wav', 'wrong.wav']) {
assert.equal(existsSync(resolve(root, 'douyin-minigame', 'audio', cue)), true, `${cue} should ship in the Douyin package`);
}
@@ -117,6 +177,7 @@ test('douyin release build injects douyin-specific ad units and private AppID',
});
const bundle = readFileSync(douyinOutput, 'utf8');
const privateConfig = JSON.parse(readFileSync(douyinPrivateConfigOutput, 'utf8'));
+ const projectConfig = JSON.parse(readFileSync(douyinProjectOutput, 'utf8'));
assert.match(bundle, /ttad-real-revive-test/);
assert.match(bundle, /ttad-real-decode-test/);
@@ -124,6 +185,7 @@ test('douyin release build injects douyin-specific ad units and private AppID',
assert.doesNotMatch(bundle, /shared-ad-revive-should-not-win/);
assert.match(bundle, /releaseMode:\s*true/);
assert.equal(privateConfig.appid, 'tt_real_release_test_appid');
+ assert.equal(projectConfig.appid, 'tt_real_release_test_appid');
assert.equal(privateConfig.projectname, 'MINIGAME_DOUYIN_TEST');
} finally {
if (existsSync(douyinPrivateConfigOutput)) rmSync(douyinPrivateConfigOutput, { force: true });
diff --git a/tests/canvasAssets.test.js b/tests/canvasAssets.test.js
index 7199580..1415d63 100644
--- a/tests/canvasAssets.test.js
+++ b/tests/canvasAssets.test.js
@@ -5,7 +5,9 @@ import { createCanvasAssetStore, getCanvasVisualAssetManifest } from '../platfor
test('Canvas visual manifest maps every shipped CCTV state and production component family', () => {
const manifest = getCanvasVisualAssetManifest();
- assert.equal(Object.keys(manifest.cctv).length, 24);
+ assert.equal(Object.keys(manifest.cctv).length, 25);
+ assert.equal(Object.keys(manifest.v5Cctv).length, 8);
+ assert.equal(manifest.v5Cctv.investigation, 'visual/cctv/v5_02_investigation_mobile.png');
assert.equal(manifest.cctv['13_entity_near'], 'visual/cctv/13_entity_near_mobile.png');
assert.equal(manifest.buttons.danger, 'visual/buttons/btn_stop_danger.png');
assert.equal(manifest.buttons.disabled, 'visual/buttons/btn_disabled.png');
@@ -28,11 +30,12 @@ test('Canvas asset store preloads real images and exposes loaded state assets',
});
store.preload();
- assert.equal(store.getStatus().total, 38);
- assert.equal(store.getStatus().loaded, 38);
+ assert.equal(store.getStatus().total, 46);
+ assert.equal(store.getStatus().loaded, 46);
assert.equal(store.getStatus().failed, 0);
assert.match(store.getCctv('13_entity_near').src, /13_entity_near_mobile\.png$/);
+ assert.match(store.getV5Cctv('investigation').src, /v5_02_investigation_mobile\.png$/);
assert.match(store.getButton('danger').src, /btn_stop_danger\.png$/);
assert.match(store.getOverlay('frame').src, /overlay_cctv_frame\.png$/);
- assert.equal(created.length, 38);
+ assert.equal(created.length, 46);
});
diff --git a/tests/canvasRenderer.test.js b/tests/canvasRenderer.test.js
index 502487f..7c64cdb 100644
--- a/tests/canvasRenderer.test.js
+++ b/tests/canvasRenderer.test.js
@@ -7,7 +7,7 @@ import { createInitialState } from '../src/state.js';
import { loadSkin } from '../src/skinManager.js';
import securitySkin from '../src/skins/security/skin.json' with { type: 'json' };
import elevatorSkin from '../src/skins/elevator/skin.json' with { type: 'json' };
-import { getCanvasActionButtons, getCanvasCctvTreatment, getCanvasFailureOverlayCopy, getCanvasLayout, getCanvasMeterBars, getCanvasMuteControl, getCanvasStaticLabels, getCanvasStatusItems, getCanvasVisibleActionButtons, getCanvasVisibleLogs, onCanvasClick } from '../platform/canvasRenderer.js';
+import { getCanvasActionButtons, getCanvasCameraTabs, getCanvasCctvTreatment, getCanvasFailureOverlayCopy, getCanvasLayout, getCanvasMeterBars, getCanvasMuteControl, getCanvasOverlayCloseButton, getCanvasOverlayModel, getCanvasProtocolItems, getCanvasProtocolSummary, getCanvasStaticLabels, getCanvasStatusItems, getCanvasToolButtons, getCanvasViewportMetrics, getCanvasVisibleActionButtons, getCanvasVisibleLogs, getV5CctvScreenId, onCanvasClick } from '../platform/canvasRenderer.js';
import { getDomLabels } from '../src/uiLabels.js';
const canvasRendererSource = readFileSync(new URL('../platform/canvasRenderer.js', import.meta.url), 'utf8');
@@ -15,7 +15,7 @@ const canvasRendererSource = readFileSync(new URL('../platform/canvasRenderer.js
test('Canvas V4 uses one dominant CCTV, three large readings and a two-button thumb zone', () => {
const layout = getCanvasLayout(1334);
const cssScale = 393 / 750;
- assert.ok(layout.monitor.w > 700 && layout.monitor.h >= 520);
+ assert.ok(layout.monitor.w > 700 && layout.monitor.h >= 479);
assert.ok(layout.readings.y > layout.monitor.y + layout.monitor.h);
assert.ok(layout.actions.y > layout.readings.y + layout.readings.h);
assert.equal(layout.actions.columns, 2);
@@ -27,6 +27,170 @@ test('Canvas V4 uses one dominant CCTV, three large readings and a two-button th
assert.equal(layout.status, undefined);
});
+test('Canvas V5 reserves native protocol and CAM rows without displacing the dominant CCTV', () => {
+ const layout = getCanvasLayout(1334);
+ assert.ok(layout.protocolBar.y > layout.topbar.y + layout.topbar.h);
+ assert.ok(layout.cameraTabs.y > layout.protocolBar.y + layout.protocolBar.h);
+ assert.ok(layout.monitor.y > layout.cameraTabs.y + layout.cameraTabs.h);
+ assert.ok(layout.monitor.h >= 479);
+ assert.ok(layout.monitor.h > layout.protocolBar.h + layout.cameraTabs.h);
+});
+
+test('Canvas V5 layout matches both official portrait acceptance viewports', () => {
+ const cases = [
+ { width: 393, height: 852, safeTop: 28, expectedCctv: 360 },
+ { width: 360, height: 640, safeTop: 22, expectedCctv: 230 },
+ ];
+ for (const item of cases) {
+ const ratio = 750 / item.width;
+ const metrics = getCanvasViewportMetrics({
+ windowWidth: item.width,
+ windowHeight: item.height,
+ safeArea: { top: item.safeTop },
+ });
+ const layout = getCanvasLayout(metrics.height, metrics.safeTop);
+ const css = value => value / ratio;
+ assert.ok(Math.abs(css(layout.monitor.h) - item.expectedCctv) <= 2,
+ `${item.width}x${item.height} CCTV height follows the handoff`);
+ assert.ok(css(layout.actions.buttonH) >= 48, 'primary actions retain 48px touch height');
+ assert.ok(css(layout.feedback.y + layout.feedback.h) <= item.height, 'feedback remains inside viewport');
+ assert.ok(layout.monitor.h > layout.protocolBar.h + layout.cameraTabs.h, 'CCTV remains the largest gameplay surface');
+ }
+});
+
+test('Canvas V5 keeps large invisible touch targets around compact camera and tool visuals', () => {
+ const layout = getCanvasLayout(1334);
+ assert.ok(layout.cameraTabs.hitH >= 90);
+ assert.ok(layout.cameraTabs.hitY < layout.cameraTabs.y);
+ assert.ok(layout.tools.hitH >= 96);
+ assert.ok(layout.tools.hitY < layout.tools.y);
+});
+
+test('Canvas V5 exposes power and contamination telemetry in the live feedback surface', () => {
+ assert.match(canvasRendererSource, /电力/);
+ assert.match(canvasRendererSource, /污染/);
+ assert.match(canvasRendererSource, /state\.contamination\?\.value/);
+});
+
+test('Canvas V5 derives protocol summaries and available CAM tabs from scheduled night state', () => {
+ const state = {
+ ...createInitialState(),
+ night: {
+ ...createInitialState().night,
+ activeProtocols: [
+ { id: 'p1', category: 'floor', text: '13 层不存在,任何请求必须封锁。' },
+ { id: 'p2', category: 'device', text: 'CAM-07 固定延迟两秒。' },
+ ],
+ currentShift: {
+ evidence: { cameras: { cam01: [], cam03: [], cam07: [] } },
+ },
+ },
+ investigation: { ...createInitialState().investigation, activeCamera: 'cam03' },
+ };
+ assert.deepEqual(getCanvasProtocolItems(state).map(item => item.id), ['p1', 'p2']);
+ assert.deepEqual(getCanvasCameraTabs(state), [
+ { id: 'cam01', label: 'CAM-01', active: false },
+ { id: 'cam03', label: 'CAM-03', active: true },
+ { id: 'cam07', label: 'CAM-07', active: false },
+ ]);
+});
+
+test('Canvas V5 compacts protocol copy so both rules remain visible on short portrait screens', () => {
+ assert.equal(getCanvasProtocolSummary([
+ { text: '13层请求必须封锁并复核来源' },
+ { text: '维修人员必须核验胸牌与目标楼层' },
+ ]), '1.13层请求必须封锁并复核来源 2.维修人员必须核验胸牌与目标楼…');
+ // 截断上限 14 字:必须保留结论子句(如“必须封锁/不属于异常”),不得截掉关键语义。
+ assert.equal(getCanvasProtocolSummary([{ text: 'CAM-07 固定延迟两秒属于校准,延迟本身不属于异常' }]),
+ '1.CAM-07 固定延迟两秒属…');
+});
+
+test('Canvas V5 CAM tabs dispatch camera switches through their native hit targets', () => {
+ const state = {
+ ...createInitialState(),
+ night: {
+ ...createInitialState().night,
+ currentShift: { evidence: { cameras: { cam01: [], cam03: [], cam07: [] } } },
+ },
+ };
+ const tabs = getCanvasLayout(1334).cameraTabs;
+ let selected = null;
+ onCanvasClick(tabs.x + tabs.w / 2, tabs.y + tabs.h / 2, state, {
+ onCameraSwitch: id => { selected = id; },
+ });
+ assert.equal(selected, 'cam03');
+});
+
+test('Canvas V5 exposes three native investigation tools with live resource state', () => {
+ const state = createInitialState();
+ state.investigation.power = 7;
+ state.investigation.tools.thermal.remaining = 2;
+ state.investigation.tools.replay.remaining = 0;
+ assert.deepEqual(getCanvasToolButtons(state), [
+ { id: 'thermal', label: '热源扫描', meta: '2次 · 8电', disabled: true },
+ { id: 'replay', label: '三秒回放', meta: '0次 · 4电', disabled: true },
+ { id: 'protocol', label: '夜班协议', meta: '不限次', disabled: false },
+ ]);
+});
+
+test('Canvas V5 derives bottom actions from roundType instead of a permanent deck', () => {
+ const initial = createInitialState();
+ const forRound = roundType => ({
+ ...initial,
+ tutorialStep: 4,
+ inspection: { id: roundType, kind: 'normal', status: 'pending', expiresAt: 9 },
+ night: { ...initial.night, roundType },
+ });
+ assert.deepEqual(getCanvasVisibleActionButtons(forRound('quick')).map(item => item.id), ['release', 'lockdown']);
+ assert.deepEqual(getCanvasVisibleActionButtons(forRound('investigation')).map(item => item.id), ['markSuspicion', 'enterClassification']);
+ assert.deepEqual(getCanvasVisibleActionButtons(forRound('identity')).map(item => item.id), ['identityRelease', 'identityReject', 'identityVerify']);
+ assert.deepEqual(getCanvasVisibleActionButtons(forRound('classification')).map(item => item.id), [
+ 'classify:person', 'classify:quantity', 'classify:space', 'classify:time', 'classify:device', 'classify:dynamic',
+ ]);
+ assert.deepEqual(getCanvasVisibleActionButtons(forRound('highRisk')).map(item => item.id), [
+ 'highRisk:emergencyStop', 'highRisk:restart', 'highRisk:lockdownFloor',
+ ]);
+ assert.ok(getCanvasVisibleActionButtons(forRound('identity')).length < 7);
+});
+
+test('Canvas V5 exposes a bounded close target for native overlays', () => {
+ const button = getCanvasOverlayCloseButton(1334, 0);
+ assert.ok(button.w >= 500);
+ assert.ok(button.h >= 60);
+ assert.ok(button.y > 0);
+});
+
+test('Canvas V5 exposes protocol query and debrief as native overlay models', () => {
+ const state = createInitialState();
+ state.night.overlay = 'protocolQuery';
+ state.night.protocolQuery = [{ id: 'p1', text: '必须核验胸牌' }];
+ assert.deepEqual(getCanvasOverlayModel(state), {
+ type: 'protocolQuery', title: '夜班协议查询', lines: ['必须核验胸牌'], action: 'closeOverlay',
+ });
+ state.night.overlay = 'debrief';
+ state.night.debrief = { summary: { decisions: 2, accuracy: 0.5, peakContamination: 80 }, ending: { name: '带回来的夜班', summary: '污染进入下一次值守。' } };
+ assert.equal(getCanvasOverlayModel(state).title, '局后复盘 · 带回来的夜班');
+ assert.match(getCanvasOverlayModel(state).lines.join(' '), /50%/);
+});
+
+test('Canvas V5 native tool and dynamic action hit targets dispatch semantic callbacks', () => {
+ const state = createInitialState();
+ state.tutorialStep = 4;
+ state.inspection = { id: 'shift', kind: 'normal', status: 'pending', expiresAt: 9 };
+ state.night.roundType = 'investigation';
+ const layout = getCanvasLayout(1334);
+ let tool = null;
+ let action = null;
+ onCanvasClick(layout.tools.x + 20, layout.tools.y + layout.tools.h / 2, state, {
+ onTool: id => { tool = id; },
+ });
+ onCanvasClick(layout.actions.x + 30, layout.actions.startY + 20, state, {
+ onAction: id => { action = id; },
+ });
+ assert.equal(tool, 'thermal');
+ assert.equal(action, 'markSuspicion');
+});
+
test('Canvas mute control remains reachable before and during play', () => {
const state = createInitialState();
const preStartControl = getCanvasMuteControl(1334, 0, false);
@@ -246,12 +410,15 @@ test('canvas renderer distinguishes success settlement from failure', () => {
test('canvas CCTV treatment consumes the shared cctvState mapping', () => {
assert.deepEqual(getCanvasCctvTreatment('00_idle_closed'), {
tint: 'rgba(97,255,190,0.05)', darkness: 0, entity: false, glitch: false, threat: false,
+ border: 'rgba(121,214,163,0.34)',
});
assert.equal(getCanvasCctvTreatment('13_entity_near').entity, true);
assert.equal(getCanvasCctvTreatment('20_threat_high').threat, true);
+ assert.equal(getCanvasCctvTreatment('20_threat_high').border, 'rgba(255,77,109,0.85)');
assert.equal(getCanvasCctvTreatment('10_signal_lost').glitch, true);
- assert.match(canvasRendererSource, /const cctvState = motion\?\.cctvState \|\| baseVisual\.cctvState/, 'Canvas scene should prefer the live motion timeline and fall back to deriveVisualState');
+ assert.match(canvasRendererSource, /const cctvState = motion\?\.cctvState[\s\S]*state\?\.night\?\.currentShift\?\.visualState[\s\S]*baseVisual\.cctvState/);
assert.match(canvasRendererSource, /getCanvasCctvTreatment\(cctvState\)/);
+ assert.match(canvasRendererSource, /treatment\.border/, 'threat border must be consumed by the scene stroke');
});
test('canvas CCTV effects consume the pausable motion frame clock', () => {
@@ -261,23 +428,75 @@ test('canvas CCTV effects consume the pausable motion frame clock', () => {
assert.doesNotMatch(canvasRendererSource, /tearY[\s\S]{0,80}Date\.now\(\)/);
});
-test('canvas monitor masks baked CCTV answers and replaces them with neutral runtime clues', () => {
- assert.match(canvasRendererSource, /状态图含固定英文诊断与固定楼层/);
- assert.match(canvasRendererSource, /\['phantom_floor', 'floor_jump', 'negative_floor'\]/);
- assert.match(canvasRendererSource, /源图已裁掉烘焙答案区/);
+test('canvas CCTV cover-fills the fixed viewport without stretch or black bars', () => {
+ const drawBlock = canvasRendererSource.match(/function drawCctvImage[\s\S]*?\n}/)?.[0] || '';
+ assert.match(drawBlock, /drawImageCover\(image, x, y, w, h\)/,
+ 'CCTV art must cover-fill the fixed window so tall phones keep the source aspect ratio');
+ assert.doesNotMatch(drawBlock, /cropTop|cropBottom|usableH|drawImageContain/);
+ assert.doesNotMatch(drawBlock, /ctx\.drawImage\(image, x, y, w, h\)/,
+ 'direct stretch distorts the cabin by 1.5x on 393x852');
+});
+
+test('canvas V5 round types select handoff scene art with motion fallback', () => {
+ assert.equal(getV5CctvScreenId({ night: { roundType: 'quick', currentShift: { id: 'x' } } }), 'quick');
+ assert.equal(getV5CctvScreenId({ night: { roundType: 'identity', currentShift: { id: 'x' } } }), 'identity');
+ assert.equal(getV5CctvScreenId({ night: { roundType: 'highRisk', currentShift: { id: 'x' } } }), 'highRisk');
+ assert.equal(getV5CctvScreenId({ night: { roundType: 'quick', currentShift: { id: 'x' }, overlay: 'protocolQuery' } }), 'protocolQuery');
+ assert.equal(getV5CctvScreenId({ night: { roundType: 'quick' } }), null);
+ assert.match(canvasRendererSource, /motion\?\.active \? null : getV5CctvScreenId\(state\)/,
+ 'motion timelines must fall back to the 24-state machine art');
+ assert.match(canvasRendererSource, /assetStore\?\.getV5Cctv\(v5ScreenId\)[\s\S]{0,120}assetStore\?\.getCctv\(cctvState\)/,
+ 'V5 handoff art must be preferred with the state machine as fallback');
+});
+
+test('canvas press feedback marks taps and shades pressed controls', () => {
+ assert.match(canvasRendererSource, /export function noteCanvasPress/);
+ assert.match(canvasRendererSource, /noteCanvasPress\(cameraTabs\[index\]\.id\)/);
+ assert.match(canvasRendererSource, /noteCanvasPress\(tools\[index\]\.id\)/);
+ assert.match(canvasRendererSource, /noteCanvasPress\(buttons\[i\]\.id\)/);
+ assert.match(canvasRendererSource, /drawPressShade\(bx, by, buttonW, buttonH, getPressDepth\(btn\.id\)\)/);
+ assert.match(canvasRendererSource, /drawPressShade\(x, layout\.y, buttonW, layout\.h, getPressDepth\(tool\.id\)\)/);
+});
+
+test('canvas CCTV atmosphere uses the supplied CRT treatment layers and runtime camera HUD', () => {
+ assert.match(canvasRendererSource, /getOverlay\('scanlines'\)/);
+ assert.match(canvasRendererSource, /getOverlay\('vignette'\)/);
+ assert.match(canvasRendererSource, /getOverlay\('frame'\)/);
+ assert.match(canvasRendererSource, /NIGHT WATCH/);
+ assert.match(canvasRendererSource, /drawCctvAtmosphere\(state, x, y, w, h, treatment, frameTime\)/);
+ assert.match(canvasRendererSource, /frameTime \/ 1800/);
+ assert.match(canvasRendererSource, /treatment\.threat \? COLORS\.red/);
+});
+
+test('canvas CCTV atmosphere remains bounded to the CCTV clip', () => {
+ assert.match(canvasRendererSource, /function drawCctvAtmosphere[\s\S]*?ctx\.rect\(x, y, w, h\)/);
+ assert.match(canvasRendererSource, /function drawCctvAtmosphere[\s\S]*?ctx\.clip\(\)/);
+ assert.match(canvasRendererSource, /function drawCctvAtmosphere[\s\S]*?ctx\.restore\(\)/);
+});
+
+test('canvas pending scan sweep animates with the pausable frame clock', () => {
+ assert.match(canvasRendererSource, /sweepPhase = \(frameTime \/ 2100\)/,
+ 'scan sweep must move over time instead of a static band');
+ assert.doesNotMatch(canvasRendererSource, /\[\[alert, 0\.72\], \[glitchOverlay, 0\.36\], \[sweep, 0\.28\]\]/);
+});
+
+test('canvas monitor uses text-free replacement art and runtime-owned HUD only', () => {
+ assert.match(canvasRendererSource, /替换图无烘焙 HUD/);
assert.match(canvasRendererSource, /始终显示实际楼层/);
assert.match(canvasRendererSource, /画面楼层/);
+ assert.doesNotMatch(canvasRendererSource, /hudShade|cropTop|cropBottom/);
assert.doesNotMatch(canvasRendererSource, /CABIN FEED|SIGNAL VARIANCE|ANOMALY CONFIRMED/);
assert.doesNotMatch(canvasRendererSource, /fillText\(['"]FLOOR MISMATCH/);
assert.doesNotMatch(canvasRendererSource, /const barH = 80|fillRect\(x, barY, w, barH\)/,
- '不得用黑条遮挡移动电梯素材;固定楼层文字应在源素材中局部修除');
+ '不得用黑条遮挡移动电梯素材;固定楼层文字必须不存在于替换图');
});
test('Canvas V4 render path removes dashboard clutter and keeps gameplay type readable', () => {
const renderBlock = canvasRendererSource.match(/export function render[\s\S]*?\n}/)?.[0] || '';
assert.doesNotMatch(renderBlock, /drawStatusPanel/);
assert.doesNotMatch(renderBlock, /drawLogs/);
- assert.match(renderBlock, /drawRuleStrip/);
+ assert.match(renderBlock, /drawProtocolBar/);
+ assert.match(renderBlock, /drawCameraTabs/);
assert.match(renderBlock, /drawReadings/);
assert.match(canvasRendererSource, /bold 34px [^\n]*Microsoft YaHei/);
assert.match(canvasRendererSource, /font = '26px [^\n]*Microsoft YaHei/);
diff --git a/tests/contamination.test.js b/tests/contamination.test.js
new file mode 100644
index 0000000..4e2e47f
--- /dev/null
+++ b/tests/contamination.test.js
@@ -0,0 +1,60 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ applyDecisionContamination,
+ createContaminationState,
+ changeContamination,
+ getContaminationTier,
+ deriveContaminationEffects,
+} from '../src/contamination.js';
+import { createInitialState } from '../src/state.js';
+
+test('a wrong decision changes later information reliability without ending the night', () => {
+ const base = createContaminationState(48);
+ const changed = applyDecisionContamination(base, {
+ correct: false,
+ contentId: 'device_camera_substitution',
+ contaminationEffects: { onMiss: 12, onCorrect: -2 },
+ });
+
+ assert.equal(changed.value, 60);
+ const effects = deriveContaminationEffects(changed.value);
+ assert.equal(effects.tier, 'medium');
+ assert.ok(effects.unreliableVerificationPaths.includes('panel'));
+ assert.equal('gameOver' in changed, false);
+});
+
+test('contamination starts at zero and clamps to 0-100', () => {
+ const base = createContaminationState();
+ assert.equal(base.value, 0);
+ assert.equal(changeContamination(base, 140, 'test').value, 100);
+ assert.equal(changeContamination(base, -20, 'test').value, 0);
+});
+
+test('contamination tiers use the V5 boundaries', () => {
+ assert.equal(getContaminationTier(0), 'normal');
+ assert.equal(getContaminationTier(26), 'light');
+ assert.equal(getContaminationTier(51), 'medium');
+ assert.equal(getContaminationTier(76), 'severe');
+});
+
+test('contamination records causal history', () => {
+ const changed = changeContamination(createContaminationState(), 18, 'released_anomaly');
+ assert.equal(changed.history.length, 1);
+ assert.deepEqual(changed.history[0], { delta: 18, reason: 'released_anomaly', value: 18 });
+});
+
+test('initial game state carries the Phase A contamination state', () => {
+ const state = createInitialState();
+ assert.deepEqual(state.contamination, createContaminationState());
+});
+
+test('contamination effects never reveal whether the current shift is anomalous', () => {
+ for (const value of [0, 30, 60, 90]) {
+ const effects = deriveContaminationEffects(value);
+ assert.equal('isAnomaly' in effects, false);
+ assert.equal('correctDecision' in effects, false);
+ assert.ok(effects.reliableVerificationPaths.length >= 1);
+ }
+});
diff --git a/tests/contentSchemasV5.test.js b/tests/contentSchemasV5.test.js
new file mode 100644
index 0000000..29f1140
--- /dev/null
+++ b/tests/contentSchemasV5.test.js
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+
+const readJson = path => JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'));
+
+const schemaFiles = [
+ 'schemas/protocol.schema.json',
+ 'schemas/normal-shift.schema.json',
+ 'schemas/anomaly-content.schema.json',
+ 'schemas/event-chain.schema.json',
+ 'schemas/passenger.schema.json',
+ 'schemas/ending.schema.json',
+];
+const contentFiles = [
+ 'src/content/protocols.json',
+ 'src/content/normalShifts.json',
+ 'src/content/anomalies.json',
+ 'src/content/eventChains.json',
+ 'src/content/passengers.json',
+ 'src/content/endings.json',
+];
+
+test('all V5 Phase A schemas are valid self-contained JSON schemas', () => {
+ for (const path of schemaFiles) {
+ const schema = readJson(path);
+ assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema');
+ assert.equal(schema.type, 'object');
+ assert.ok(Array.isArray(schema.required));
+ assert.equal(schema.additionalProperties, false);
+ }
+});
+
+test('all V5 content containers exist as JSON arrays', () => {
+ for (const path of contentFiles) assert.ok(Array.isArray(readJson(path)), path);
+});
+
+test('anomaly schema requires evidence, protocol, contamination and silent-play fields', () => {
+ const schema = readJson('schemas/anomaly-content.schema.json');
+ for (const field of ['screenData', 'panelData', 'primaryConflict', 'decision', 'explanation',
+ 'resolutionAction', 'availableTools', 'protocolTags', 'contaminationEffects', 'silentEvidence']) {
+ assert.ok(schema.required.includes(field), field);
+ }
+});
diff --git a/tests/debriefTimeline.test.js b/tests/debriefTimeline.test.js
new file mode 100644
index 0000000..f062a25
--- /dev/null
+++ b/tests/debriefTimeline.test.js
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import endings from '../src/content/endings.json' with { type: 'json' };
+import {
+ buildDebriefTimeline,
+ selectNightEnding,
+} from '../src/debriefTimeline.js';
+
+test('debrief timeline reports decisions, chain stages and contamination in sequence', () => {
+ const report = buildDebriefTimeline({
+ decisions: [
+ { sequence: 1, contentId: 'normal_shift_01', correct: true, choice: 'release' },
+ { sequence: 3, contentId: 'device_camera_substitution', correct: false, choice: 'release' },
+ ],
+ eventHistory: [{ sequence: 2, chainId: 'camera_replacement', stepId: 'cam07_delay', correct: true }],
+ contaminationHistory: [{ sequence: 3, delta: 12, value: 60, reason: 'wrong-decision' }],
+ });
+
+ assert.deepEqual(report.timeline.map(item => item.sequence), [1, 2, 3, 3]);
+ assert.equal(report.summary.correct, 1);
+ assert.equal(report.summary.wrong, 1);
+ assert.equal(report.summary.accuracy, 0.5);
+ assert.equal(report.summary.peakContamination, 60);
+});
+
+test('ending selection is deterministic and honors higher-priority chain consequences', () => {
+ const ending = selectNightEnding(endings, {
+ contamination: 82,
+ accuracy: 0.7,
+ flags: ['camera_chain_compromised'],
+ });
+ assert.equal(ending.id, 'camera_taken');
+});
diff --git a/tests/douyinBundleSmoke.test.js b/tests/douyinBundleSmoke.test.js
index 8e3075e..24d6a8b 100644
--- a/tests/douyinBundleSmoke.test.js
+++ b/tests/douyinBundleSmoke.test.js
@@ -78,8 +78,9 @@ test('generated Douyin bundle boots against the tt Canvas contract', () => {
assert.ok(text.includes('夜班值守许可'));
assert.ok(text.includes('开始接管'));
assert.ok(text.includes('侧边栏入口'));
- assert.equal(imageSources.length, 38, 'all shipped Canvas visual assets should preload');
+ assert.equal(imageSources.length, 46, 'all shipped Canvas visual assets should preload');
assert.ok(imageSources.includes('visual/cctv/00_idle_closed_mobile.png'));
+ assert.ok(imageSources.includes('visual/cctv/v5_02_investigation_mobile.png'));
assert.ok(imageSources.includes('visual/buttons/btn_stop_danger.png'));
assert.ok(drawImageCalls >= 1, 'the first render should draw the production CCTV state');
@@ -87,13 +88,13 @@ test('generated Douyin bundle boots against the tt Canvas contract', () => {
assert.equal(sidebarOptions?.scene, 'sidebar');
onTouchStart({ touches: [{ screenX: 375 * 390 / 750, screenY: 962 * 844 / canvas.height }] });
// 第一班教学:画面与数据一致,点击放行。
- onTouchStart({ touches: [{ screenX: 199 * 390 / 750, screenY: 1396 * 844 / canvas.height }] });
+ onTouchStart({ touches: [{ screenX: 199 * 390 / 750, screenY: 1323 * 844 / canvas.height }] });
now += 8_000;
nextFrame();
assert.ok(text.includes('封锁'), 'first anomaly should expose the simple lockdown decision');
assert.ok(text.includes('放行'), 'release and lockdown choices should stay paired');
// 第二班教学:封锁后应由系统自动处置,不出现第二层玩家按钮。
- onTouchStart({ touches: [{ screenX: 551 * 390 / 750, screenY: 1396 * 844 / canvas.height }] });
+ onTouchStart({ touches: [{ screenX: 551 * 390 / 750, screenY: 1323 * 844 / canvas.height }] });
nextFrame();
assert.ok(text.includes('封锁成功,系统已自动处置'));
assert.ok(text.includes('等待下一班'));
diff --git a/tests/eventChain.test.js b/tests/eventChain.test.js
new file mode 100644
index 0000000..967625d
--- /dev/null
+++ b/tests/eventChain.test.js
@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ advanceEventChain,
+ createEventChainState,
+ getCurrentEventStep,
+} from '../src/eventChainEngine.js';
+
+const chain = {
+ id: 'duplicate_passenger',
+ initialFlags: [],
+ steps: [
+ { id: 'first_visit', onWrongFlags: ['trusted_duplicate'] },
+ { id: 'repeated_motion', onWrongFlags: ['motion_ignored'] },
+ { id: 'simultaneous_presence', onWrongFlags: ['chain_compromised'] },
+ ],
+ consequences: [{ flag: 'chain_compromised', contaminationDelta: 18 }],
+};
+
+test('event chain advances one stage and preserves wrong-decision consequences', () => {
+ const initial = createEventChainState([chain]);
+ const first = advanceEventChain(initial, chain, { correct: false });
+
+ assert.equal(first.state.chains.duplicate_passenger.stepIndex, 1);
+ assert.ok(first.state.flags.includes('trusted_duplicate'));
+ assert.equal(getCurrentEventStep(first.state, chain).id, 'repeated_motion');
+});
+
+test('event chain completion emits configured long-term consequences', () => {
+ let state = createEventChainState([chain]);
+ state = advanceEventChain(state, chain, { correct: true }).state;
+ state = advanceEventChain(state, chain, { correct: true }).state;
+ const completed = advanceEventChain(state, chain, { correct: false });
+
+ assert.equal(completed.completed, true);
+ assert.ok(completed.state.flags.includes('chain_compromised'));
+ assert.deepEqual(completed.consequences, chain.consequences);
+});
diff --git a/tests/evidenceEngine.test.js b/tests/evidenceEngine.test.js
new file mode 100644
index 0000000..c3d0e82
--- /dev/null
+++ b/tests/evidenceEngine.test.js
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ compareCoreEvidence,
+ evaluateEvidence,
+ evaluateInvestigationEvidence,
+ isEvidenceJudgeableWithoutAudio,
+} from '../src/evidenceEngine.js';
+
+test('one tool clue is insufficient but two independent sources can corroborate a conflict', () => {
+ const thermalOnly = evaluateInvestigationEvidence([
+ { id: 'thermal_none', source: 'thermal', conflictKey: 'presence', observation: '未检测到生命热源。', contradicts: true },
+ ]);
+ assert.equal(thermalOnly.ready, false);
+ assert.equal(thermalOnly.decision, null);
+
+ const corroborated = evaluateInvestigationEvidence([
+ { id: 'thermal_none', source: 'thermal', conflictKey: 'presence', observation: '未检测到生命热源。', contradicts: true },
+ { id: 'cam03_empty', source: 'cam03', conflictKey: 'presence', observation: '电梯厅没有入梯记录。', contradicts: true },
+ ]);
+ assert.equal(corroborated.ready, true);
+ assert.equal(corroborated.decision, 'lockdown');
+ assert.deepEqual(corroborated.verificationPaths, ['cam03', 'thermal']);
+});
+
+test('matching floor passenger and door evidence is normal', () => {
+ const data = { floor: 3, passengers: 1, door: 'closed' };
+ assert.deepEqual(compareCoreEvidence(data, data), []);
+ assert.equal(evaluateEvidence({ screenData: data, panelData: data }).decision, 'release');
+});
+
+test('single-field contradiction names the exact conflicting field', () => {
+ const result = evaluateEvidence({
+ screenData: { floor: 3, passengers: 1, door: 'closed' },
+ panelData: { floor: 3, passengers: 0, door: 'closed' },
+ });
+
+ assert.equal(result.decision, 'lockdown');
+ assert.deepEqual(result.conflicts.map(item => item.field), ['passengers']);
+ assert.match(result.explanation, /人数/);
+});
+
+test('audio cue is never the only verification path', () => {
+ const shift = {
+ screenData: { floor: 3, passengers: 1, door: 'closed' },
+ panelData: { floor: 3, passengers: 0, door: 'closed' },
+ audioCue: 'weight_sensor',
+ evidence: { cameras: ['cam01'], tools: [] },
+ };
+ assert.equal(isEvidenceJudgeableWithoutAudio(shift), true);
+});
+
+test('evidence evaluation does not reveal the answer through visual tone', () => {
+ const result = evaluateEvidence({
+ screenData: { floor: 4, passengers: 0, door: 'open' },
+ panelData: { floor: 4, passengers: 0, door: 'closed' },
+ });
+ assert.equal(result.presentationTone, 'neutral');
+ assert.equal(result.highlightConflictBeforeDecision, false);
+});
diff --git a/tests/highRiskResolution.test.js b/tests/highRiskResolution.test.js
new file mode 100644
index 0000000..f5dea26
--- /dev/null
+++ b/tests/highRiskResolution.test.js
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ createHighRiskState,
+ resolveHighRiskAction,
+} from '../src/highRiskResolution.js';
+
+test('correct high-risk action resolves the event and consumes its real resource cost', () => {
+ const state = createHighRiskState({ power: 40 });
+ const event = { id: 'shaft_entry', acceptedActions: ['emergencyStop'], costs: { emergencyStop: 15 } };
+ const result = resolveHighRiskAction(state, event, 'emergencyStop');
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.correct, true);
+ assert.equal(result.state.power, 25);
+ assert.equal(result.state.resolvedEvents.includes('shaft_entry'), true);
+});
+
+test('wrong high-risk action changes later shifts instead of ending immediately', () => {
+ const state = createHighRiskState({ power: 40 });
+ const event = {
+ id: 'camera_replacement',
+ acceptedActions: ['restart'],
+ costs: { lockdownFloor: 10 },
+ wrongModifiers: { lockdownFloor: 'unreliable_cam07' },
+ };
+ const result = resolveHighRiskAction(state, event, 'lockdownFloor');
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.correct, false);
+ assert.equal(result.state.power, 30);
+ assert.ok(result.state.nextShiftModifiers.includes('unreliable_cam07'));
+ assert.equal(result.state.gameOver, false);
+});
diff --git a/tests/identitySystem.test.js b/tests/identitySystem.test.js
new file mode 100644
index 0000000..a0e809b
--- /dev/null
+++ b/tests/identitySystem.test.js
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ countPassengersForPanel,
+ verifyPassengerIdentity,
+} from '../src/identitySystem.js';
+
+const maintenance = {
+ id: 'worker_001',
+ name: '张伟',
+ role: 'maintenance',
+ badge: 'yellow',
+ allowedFloors: ['B2', '8'],
+ countMode: 'ignore',
+};
+
+test('maintenance identity requires the configured badge and allowed floor', () => {
+ const valid = verifyPassengerIdentity(maintenance, { badge: 'yellow', requestedFloor: '8' });
+ assert.equal(valid.valid, true);
+ assert.deepEqual(valid.conflicts, []);
+
+ const wrongBadge = verifyPassengerIdentity(maintenance, { badge: 'red', requestedFloor: '8' });
+ assert.equal(wrongBadge.valid, false);
+ assert.deepEqual(wrongBadge.conflicts, ['badge']);
+});
+
+test('passenger count follows countMode instead of visual headcount', () => {
+ const passenger = { id: 'resident_001', countMode: 'normal' };
+ assert.equal(countPassengersForPanel([maintenance, passenger]), 1);
+});
diff --git a/tests/importedAssetCoverage.test.js b/tests/importedAssetCoverage.test.js
index b757a9d..2becea6 100644
--- a/tests/importedAssetCoverage.test.js
+++ b/tests/importedAssetCoverage.test.js
@@ -7,6 +7,7 @@ const root = new URL('../', import.meta.url);
const css = readFileSync(new URL('../styles.css', import.meta.url), 'utf8');
const readme = readFileSync(new URL('../games/find-anomaly/elevator-console/README.md', import.meta.url), 'utf8');
const runtimeMap = readFileSync(new URL('../games/find-anomaly/elevator-console/runtime-map.md', import.meta.url), 'utf8');
+const canvasAssets = readFileSync(new URL('../platform/canvasAssets.js', import.meta.url), 'utf8');
const gameAssetsRoot = new URL('../games/find-anomaly/elevator-console/assets/', import.meta.url);
const visualAssetsRoot = new URL('../games/find-anomaly/elevator-console/assets/abnormal_elevator_visual_assets/', import.meta.url);
const uiKitRoot = new URL('../games/find-anomaly/elevator-console/assets/abnormal_elevator_ui_kit/', import.meta.url);
@@ -31,30 +32,34 @@ function filename(fileUrl) {
test('imported first-game asset pack lives under the elevator game directory', () => {
const allImportedFiles = listFiles(gameAssetsRoot);
- assert.equal(allImportedFiles.length, 73);
+ assert.equal(allImportedFiles.length, 77);
for (const file of allImportedFiles) {
const relativePath = relative(root.pathname.slice(1), file.pathname.slice(1)).replaceAll('\\', '/');
assert.match(relativePath, /^games\/find-anomaly\/elevator-console\/assets\//);
}
});
-test('all runtime-ready visual PNGs from the imported pack are referenced by CSS', () => {
+test('all runtime-ready visual PNGs are wired into their CSS or Canvas runtime', () => {
const visualFiles = listFiles(visualAssetsRoot);
- const runtimePngs = visualFiles
- .filter(file => filename(file).endsWith('.png'))
- .filter(file => !file.pathname.includes('/spritesheets/'));
+ const runtimePngs = visualFiles.filter(file => filename(file).endsWith('.png'));
+ const v5Pngs = runtimePngs.filter(file => filename(file).startsWith('v5_'));
+ const legacyPngs = runtimePngs.filter(file => !filename(file).startsWith('v5_'));
- assert.equal(runtimePngs.length, 62);
- for (const file of runtimePngs) {
+ assert.equal(legacyPngs.length, 62);
+ assert.equal(v5Pngs.length, 8);
+ for (const file of legacyPngs) {
assert.match(css, new RegExp(filename(file).replace(/[.]/g, '\\.')), `${filename(file)} should be wired into runtime CSS`);
}
+ for (const file of v5Pngs) {
+ assert.match(canvasAssets, new RegExp(filename(file).replace(/[.]/g, '\\.')), `${filename(file)} should be wired into Canvas assets`);
+ }
});
test('reference-only imported files stay out of runtime CSS and are documented', () => {
for (const file of listFiles(uiKitRoot)) {
assert.doesNotMatch(css, new RegExp(filename(file).replace(/[.]/g, '\\.')), `${filename(file)} should stay reference-only`);
}
- for (const referenceOnly of ['button_spritesheet.png', 'cctv_states_contact_sheet.png', 'abnormal-elevator-ui-kit.html']) {
+ for (const referenceOnly of ['abnormal-elevator-ui-kit.html']) {
assert.doesNotMatch(css, new RegExp(referenceOnly.replace(/[.]/g, '\\.')), `${referenceOnly} should not be loaded as runtime UI`);
}
assert.match(readme, /资源接入状态/);
diff --git a/tests/investigationTools.test.js b/tests/investigationTools.test.js
new file mode 100644
index 0000000..a2a46e2
--- /dev/null
+++ b/tests/investigationTools.test.js
@@ -0,0 +1,93 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ createInvestigationState,
+ switchCamera,
+ useInvestigationTool,
+} from '../src/investigationTools.js';
+
+test('camera switch exposes only the evidence assigned to that camera', () => {
+ const state = createInvestigationState();
+ const shift = {
+ cameras: ['cam01', 'cam03', 'cam07'],
+ evidence: {
+ cameras: {
+ cam01: [{ id: 'cam01_person', observation: '轿厢内有一名乘客。' }],
+ cam03: [{ id: 'cam03_empty', observation: '电梯厅无人进入。' }],
+ cam07: [{ id: 'cam07_entry', observation: '井道记录显示乘客提前进入。' }],
+ },
+ },
+ };
+
+ const result = switchCamera(state, 'cam03', shift);
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.state.activeCamera, 'cam03');
+ assert.deepEqual(result.visibleEvidence, shift.evidence.cameras.cam03);
+ assert.equal(result.state.discoveredEvidence.some(item => item.id === 'cam01_person'), false);
+});
+
+test('protocol query returns current rules without consuming power or charges', () => {
+ const state = createInvestigationState({ power: 40 });
+ const shift = {
+ activeProtocols: [
+ { id: 'floor_13_forbidden', text: '13 层不存在。' },
+ { id: 'cam07_delay_expected', text: 'CAM-07 固定延迟两秒。' },
+ ],
+ };
+
+ const result = useInvestigationTool(state, 'protocol', shift);
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.state.power, 40);
+ assert.equal(result.state.tools.protocol.remaining, Number.POSITIVE_INFINITY);
+ assert.deepEqual(result.discoveredEvidence, shift.activeProtocols);
+});
+
+test('replay is limited to two uses and cannot spend below zero', () => {
+ const shift = { evidence: { replay: { id: 'replay_loop', type: 'replay', observation: '三秒动作完全重复。' } } };
+ const first = useInvestigationTool(createInvestigationState({ power: 20 }), 'replay', shift);
+ const second = useInvestigationTool(first.state, 'replay', shift);
+ const third = useInvestigationTool(second.state, 'replay', shift);
+
+ assert.equal(first.accepted, true);
+ assert.equal(second.accepted, true);
+ assert.equal(second.state.power, 12);
+ assert.equal(second.state.tools.replay.remaining, 0);
+ assert.equal(third.accepted, false);
+ assert.equal(third.reason, 'no-uses');
+ assert.strictEqual(third.state, second.state);
+});
+
+test('tools reject insufficient power without consuming a charge', () => {
+ const state = createInvestigationState({ power: 7 });
+ const result = useInvestigationTool(state, 'thermal', { evidence: {} });
+
+ assert.equal(result.accepted, false);
+ assert.equal(result.reason, 'insufficient-power');
+ assert.strictEqual(result.state, state);
+ assert.equal(state.tools.thermal.remaining, 2);
+});
+
+test('thermal scan consumes one charge and power but only reveals evidence', () => {
+ const state = createInvestigationState({ power: 100 });
+ const shift = {
+ evidence: {
+ thermal: {
+ id: 'thermal_01',
+ type: 'thermal',
+ cameraId: 'cam01',
+ observation: '轿厢内检测到 1 个稳定热源。',
+ },
+ },
+ };
+
+ const result = useInvestigationTool(state, 'thermal', shift);
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.state.power, 92);
+ assert.equal(result.state.tools.thermal.remaining, 1);
+ assert.deepEqual(result.discoveredEvidence, shift.evidence.thermal);
+ assert.equal('decision' in result, false, 'investigation tools must never return the answer');
+});
diff --git a/tests/miniGameAudio.test.js b/tests/miniGameAudio.test.js
index e50749b..c74da70 100644
--- a/tests/miniGameAudio.test.js
+++ b/tests/miniGameAudio.test.js
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { createMiniGameAudio } from '../platform/miniGameAudio.js';
+import { createMiniGameAudio, getV5FeedbackProfile } from '../platform/miniGameAudio.js';
test('mini-game audio maps semantic cues to bundled local files', () => {
const played = [];
@@ -26,6 +26,66 @@ test('mini-game audio maps semantic cues to bundled local files', () => {
]);
});
+test('mini-game music uses one dedicated looping context and switches tracks without overlap', () => {
+ const contexts = [];
+ const api = {
+ createInnerAudioContext() {
+ const context = {
+ autoplay: false, loop: false, volume: 1, src: '',
+ plays: 0, pauses: 0, stops: 0,
+ play() { this.plays += 1; },
+ pause() { this.pauses += 1; },
+ stop() { this.stops += 1; },
+ seek() {}, destroy() {},
+ };
+ contexts.push(context);
+ return context;
+ },
+ };
+ const audio = createMiniGameAudio(api);
+ assert.equal(audio.setMusicState('calm'), true);
+ assert.equal(contexts.length, 1);
+ assert.equal(contexts[0].loop, true);
+ assert.equal(contexts[0].src, 'audio/bgm-night-shift-loop.wav');
+ assert.equal(contexts[0].volume, 0.12);
+
+ assert.equal(audio.setMusicState('pressure'), true);
+ assert.equal(contexts.length, 1, 'track switch must reuse the dedicated music context');
+ assert.equal(contexts[0].src, 'audio/bgm-anomaly-pressure-loop.wav');
+ assert.ok(contexts[0].stops >= 1, 'old loop must stop before pressure music starts');
+});
+
+test('music pause resume and mute are linked without double playback', () => {
+ const context = { playCount: 0, pauseCount: 0, stopCount: 0, play() { this.playCount += 1; }, pause() { this.pauseCount += 1; }, stop() { this.stopCount += 1; }, seek() {}, destroy() {} };
+ const audio = createMiniGameAudio({ createInnerAudioContext: () => context });
+ audio.setMusicState('calm');
+ audio.pauseMusic();
+ assert.equal(audio.resumeMusic(), true);
+ audio.setMuted(true);
+ assert.equal(audio.resumeMusic(), false);
+ assert.ok(context.pauseCount >= 1);
+ assert.ok(context.stopCount >= 1);
+});
+
+test('V5 interactions use distinct semantic audio and haptic profiles', () => {
+ assert.deepEqual(getV5FeedbackProfile('camera'), { cue: 'click', haptic: 'light' });
+ assert.deepEqual(getV5FeedbackProfile('tool:thermal'), { cue: 'anomaly', haptic: 'medium' });
+ assert.deepEqual(getV5FeedbackProfile('tool:replay'), { cue: 'motor', haptic: 'light' });
+ assert.deepEqual(getV5FeedbackProfile('tool:protocol'), { cue: 'boot', haptic: 'light' });
+ assert.deepEqual(getV5FeedbackProfile('classification:enter'), { cue: 'anomaly', haptic: 'medium' });
+ assert.deepEqual(getV5FeedbackProfile('classification:correct'), { cue: 'lockdown', haptic: 'medium' });
+ assert.deepEqual(getV5FeedbackProfile('classification:wrong'), { cue: 'wrong', haptic: 'heavy' });
+ assert.deepEqual(getV5FeedbackProfile('highRisk:correct'), { cue: 'lockdown', haptic: 'heavy' });
+ assert.deepEqual(getV5FeedbackProfile('highRisk:wrong'), { cue: 'wrong', haptic: 'heavy' });
+});
+
+test('V5 protocol and identity actions have distinct audio semantics', () => {
+ assert.notDeepEqual(getV5FeedbackProfile('protocol:close'), getV5FeedbackProfile('camera'));
+ assert.deepEqual(getV5FeedbackProfile('identity:verify'), { cue: 'boot', haptic: 'light' });
+ assert.deepEqual(getV5FeedbackProfile('identity:correct'), { cue: 'release', haptic: 'medium' });
+ assert.deepEqual(getV5FeedbackProfile('identity:wrong'), { cue: 'wrong', haptic: 'heavy' });
+});
+
test('mini-game audio can be muted and fails safely without host audio API', () => {
const audio = createMiniGameAudio({});
assert.equal(audio.play('click'), false);
diff --git a/tests/miniGameRuntime.test.js b/tests/miniGameRuntime.test.js
index fb7d27d..3202daf 100644
--- a/tests/miniGameRuntime.test.js
+++ b/tests/miniGameRuntime.test.js
@@ -131,6 +131,16 @@ test('mini-game runtime drives and pauses the CCTV motion timeline', () => {
assert.match(source, /cctvMotion\.reset\(\);\s*state = openInspection/, 'each new normal class must clear stale anomaly/action motion');
});
+test('mini-game runtime starts calm BGM after user gesture and switches with anomaly pressure', () => {
+ const source = readFileSync(new URL('../platform/miniGameRuntime.js', import.meta.url), 'utf8');
+ assert.match(source, /function start\(\)[\s\S]*audio\.setMusicState\('calm'\)/);
+ assert.match(source, /state\.activeAnomaly \? 'pressure' : 'calm'/);
+ assert.match(source, /pauseForAd[\s\S]*audio\.stopAll\(\)/);
+ assert.match(source, /resumeAfterAd[\s\S]*audio\.resumeMusic\(\)/);
+ assert.match(source, /onPause:[\s\S]*audio\.stopAll\(\)/);
+ assert.match(source, /onResume:[\s\S]*audio\.resumeMusic\(\)/);
+});
+
test('base 60-second mode auto-resolves reported anomalies without a second player control layer', () => {
const runtimeSource = readFileSync(new URL('../platform/miniGameRuntime.js', import.meta.url), 'utf8');
const rendererSource = readFileSync(new URL('../platform/canvasRenderer.js', import.meta.url), 'utf8');
@@ -142,6 +152,13 @@ test('base 60-second mode auto-resolves reported anomalies without a second play
assert.doesNotMatch(rendererSource.match(/export function getCanvasVisibleActionButtons[\s\S]*?\n}/)?.[0] || '', /recommended:\s*true/);
});
+test('quick V5 tutorial handoff installs the first chain shift, while later outcomes advance it', () => {
+ const source = readFileSync(new URL('../platform/miniGameRuntime.js', import.meta.url), 'utf8');
+ assert.match(source, /openScheduledNightInspection\(scheduleNextNightShift\(state, __V5_CONTENT__\)\)/);
+ assert.match(source, /function scheduleFollowingNightShift[\s\S]*advanceCurrentNightEventChain[\s\S]*openScheduledNightInspection/);
+ assert.match(source, /expiredNightShift[\s\S]*scheduleFollowingNightShift\(state, \{ correct: false \}\)/);
+});
+
test('mini-game decode action is gated by the decode rewarded-ad slot', () => {
const source = readFileSync(new URL('../platform/miniGameRuntime.js', import.meta.url), 'utf8');
assert.match(source, /const decodeAd = createMiniGameRewardedAd/, 'runtime should create a dedicated decode ad');
diff --git a/tests/nightInteraction.test.js b/tests/nightInteraction.test.js
new file mode 100644
index 0000000..c10303b
--- /dev/null
+++ b/tests/nightInteraction.test.js
@@ -0,0 +1,103 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import anomalies from '../src/content/anomalies.json' with { type: 'json' };
+import endings from '../src/content/endings.json' with { type: 'json' };
+import normalShifts from '../src/content/normalShifts.json' with { type: 'json' };
+import { createInitialState } from '../src/state.js';
+import {
+ classifyCurrentShift,
+ closeProtocolQuery,
+ createNightDebrief,
+ openProtocolQuery,
+ resolveCurrentHighRisk,
+ resolveIdentityDecision,
+ verifyCurrentIdentity,
+} from '../src/nightInteraction.js';
+
+function withShift(shift) {
+ const state = createInitialState();
+ state.night.currentShift = structuredClone(shift);
+ state.night.roundType = shift.roundType;
+ state.night.activeProtocols = [{ id: 'p1', text: '13 层请求必须封锁' }];
+ return state;
+}
+
+test('protocol query opens and closes a runtime-owned overlay without consuming resources', () => {
+ const state = withShift(anomalies[0]);
+ const opened = openProtocolQuery(state);
+ assert.equal(opened.night.overlay, 'protocolQuery');
+ assert.deepEqual(opened.night.protocolQuery.map(item => item.id), ['p1']);
+ assert.deepEqual(opened.investigation.tools, state.investigation.tools);
+ assert.equal(closeProtocolQuery(opened).night.overlay, null);
+});
+
+test('identity verification reveals evidence without forcing anomaly classification', () => {
+ const shift = normalShifts.find(item => item.id === 'normal_shift_02');
+ const result = verifyCurrentIdentity(withShift(shift));
+ assert.equal(result.accepted, true);
+ assert.equal(result.state.night.roundType, 'identity');
+ assert.match(result.state.lastFeedback, /核验结果/);
+ assert.equal(result.state.investigation.discoveredEvidence.at(-1).id, 'normal_shift_02_cam01');
+ assert.equal(result.state.night.decisions.length, 0);
+});
+
+test('identity release and reject settle normal or anomalous identities and record contamination', () => {
+ const normal = normalShifts.find(item => item.id === 'normal_shift_02');
+ const anomaly = anomalies.find(item => item.id === 'person_unknown_identity');
+ const allowed = resolveIdentityDecision(withShift(normal), 'release');
+ const rejected = resolveIdentityDecision(withShift(anomaly), 'reject');
+ const wrong = resolveIdentityDecision(withShift(anomaly), 'release');
+
+ assert.equal(allowed.accepted, true);
+ assert.equal(allowed.correct, true);
+ assert.equal(rejected.correct, true);
+ assert.equal(rejected.state.night.decisions.at(-1).choice, 'identity:reject');
+ assert.equal(wrong.correct, false);
+ assert.ok(wrong.state.contamination.value > 0);
+ assert.equal(wrong.state.gameOver, false);
+});
+
+test('classification records the category and routes high-risk anomalies to contextual disposal', () => {
+ const shift = anomalies.find(item => item.roundType === 'highRisk');
+ const result = classifyCurrentShift(withShift(shift), shift.category);
+ assert.equal(result.correct, true);
+ assert.equal(result.state.night.roundType, 'highRisk');
+ assert.equal(result.state.night.decisions.at(-1).classification, shift.category);
+ assert.equal(result.state.night.decisions.at(-1).correct, true);
+});
+
+test('high-risk disposal consumes power, records consequences and never ends the run on a mistake', () => {
+ const shift = anomalies.find(item => item.id === 'space_floor_13');
+ const classified = classifyCurrentShift(withShift(shift), shift.category).state;
+ const wrong = resolveCurrentHighRisk(classified, 'restart');
+ assert.equal(wrong.accepted, true);
+ assert.equal(wrong.correct, false);
+ assert.equal(wrong.state.gameOver, false);
+ assert.ok(wrong.state.power < classified.power);
+ assert.equal(wrong.state.night.decisions.at(-1).action, 'restart');
+});
+
+test('debrief selects chain endings from eventChainFlags and keeps modifiers as separate report data', () => {
+ const state = createInitialState();
+ state.night.eventChainFlags = ['camera_chain_compromised'];
+ state.night.nextShiftModifiers = ['unreliable_cam07'];
+ const report = createNightDebrief(state, endings);
+ assert.equal(report.ending.id, 'camera_taken');
+ assert.deepEqual(report.nextShiftModifiers, ['unreliable_cam07']);
+});
+test('debrief derives timeline, accuracy and deterministic ending from live night state', () => {
+ const state = createInitialState();
+ state.night.decisions = [
+ { sequence: 1, contentId: 'a', correct: true, choice: 'release' },
+ { sequence: 2, contentId: 'b', correct: false, classification: 'device' },
+ ];
+ state.contamination = { value: 80, tier: 'severe', history: [{ sequence: 3, value: 80 }] };
+ state.night.eventChainHistory = [
+ { chainId: 'duplicate_passenger', stepId: 'first_visit', correct: false },
+ ];
+ const report = createNightDebrief(state, endings);
+ assert.equal(report.summary.accuracy, 0.5);
+ assert.equal(report.ending.id, 'contaminated_survivor');
+ assert.equal(report.timeline.length, 4);
+});
diff --git a/tests/nightScheduler.test.js b/tests/nightScheduler.test.js
new file mode 100644
index 0000000..63e5672
--- /dev/null
+++ b/tests/nightScheduler.test.js
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import anomalies from '../src/content/anomalies.json' with { type: 'json' };
+import normalShifts from '../src/content/normalShifts.json' with { type: 'json' };
+import protocols from '../src/content/protocols.json' with { type: 'json' };
+import eventChains from '../src/content/eventChains.json' with { type: 'json' };
+import { createInitialState } from '../src/state.js';
+import {
+ advanceCurrentNightEventChain,
+ createNightSchedule,
+ scheduleNextNightShift,
+} from '../src/nightScheduler.js';
+
+const content = { anomalies, normalShifts, protocols, eventChains };
+
+test('night schedule deterministically installs protocols and the first normal shift', () => {
+ const first = createNightSchedule(createInitialState(), content, { random: () => 0, protocolCount: 3 });
+ const second = createNightSchedule(createInitialState(), content, { random: () => 0, protocolCount: 3 });
+
+ assert.deepEqual(first.night, second.night);
+ assert.equal(first.night.activeProtocols.length, 3);
+ assert.equal(first.night.currentShift.id, normalShifts[0].id);
+ assert.equal(first.night.currentShift.shiftKind, 'normal');
+ assert.equal(first.night.roundType, normalShifts[0].roundType);
+ assert.deepEqual(first.night.currentShift.activeProtocols, first.night.activeProtocols);
+ assert.ok(first.night.activeProtocols.some(protocol => (
+ protocol.protocolTags || []
+ ).some(tag => first.night.currentShift.protocolTags.includes(tag))));
+});
+
+test('next shift alternates into anomaly content and advances roundType/index without mutating input', () => {
+ const initial = createNightSchedule(createInitialState(), content, { random: () => 0 });
+ const next = scheduleNextNightShift(initial, content, { random: () => 0 });
+
+ assert.equal(initial.night.shiftIndex, 0);
+ assert.equal(initial.night.currentShift.shiftKind, 'normal');
+ assert.equal(next.night.shiftIndex, 1);
+ assert.equal(next.night.currentShift.id, anomalies[0].id);
+ assert.equal(next.night.currentShift.shiftKind, 'anomaly');
+ assert.equal(next.night.roundType, anomalies[0].roundType);
+ assert.deepEqual(next.night.currentShift.activeProtocols, next.night.activeProtocols);
+ assert.equal(next.investigation.activeCamera, 'cam01');
+ assert.deepEqual(next.investigation.discoveredEvidence, []);
+});
+
+test('event chains remain dormant during teaching and schedule their first real step after tutorial', () => {
+ const initial = createNightSchedule(createInitialState(), content, { random: () => 0 });
+ assert.equal(initial.night.activeEventChainId, 'duplicate_passenger');
+ assert.equal(initial.night.currentShift.eventChainId, undefined);
+
+ const ready = { ...initial, tutorialStep: 4 };
+ const next = scheduleNextNightShift(ready, content, { random: () => 0 });
+ assert.equal(next.night.currentShift.eventChainId, 'duplicate_passenger');
+ assert.equal(next.night.currentShift.eventChainStep, 'first_visit');
+ assert.equal(next.night.currentShift.id, 'normal_shift_02');
+ assert.equal(next.night.roundType, 'identity');
+});
+
+test('completed event chain records all three outcomes and applies flagged consequence once', () => {
+ let state = createNightSchedule(createInitialState(), content, { random: () => 0 });
+ state = { ...state, tutorialStep: 4 };
+ for (let index = 0; index < 3; index += 1) {
+ state = scheduleNextNightShift(state, content, { random: () => 0 });
+ const result = advanceCurrentNightEventChain(state, content, { correct: false });
+ assert.equal(result.advanced, true);
+ state = result.state;
+ }
+ assert.equal(state.night.eventChains.duplicate_passenger.completed, true);
+ assert.equal(state.night.eventChainHistory.length, 3);
+ assert.ok(state.night.eventChainFlags.includes('chain_compromised'));
+ assert.equal(state.contamination.value, 18);
+ assert.deepEqual(state.night.nextShiftModifiers, ['duplicate_feed']);
+ const consumed = scheduleNextNightShift(state, content, { random: () => 0 });
+ assert.deepEqual(consumed.night.currentShift.appliedModifiers, ['duplicate_feed']);
+ assert.equal(consumed.night.currentShift.visualState, '14_duplicate_subject');
+ assert.deepEqual(consumed.night.nextShiftModifiers, []);
+});
+test('scheduler rejects incomplete V5 content instead of silently creating a blank round', () => {
+ assert.throws(
+ () => createNightSchedule(createInitialState(), { normalShifts: [], anomalies, protocols }),
+ /normalShifts/,
+ );
+ assert.throws(
+ () => createNightSchedule(createInitialState(), { normalShifts, anomalies: [], protocols }),
+ /anomalies/,
+ );
+});
diff --git a/tests/protocolEngine.test.js b/tests/protocolEngine.test.js
new file mode 100644
index 0000000..52939bd
--- /dev/null
+++ b/tests/protocolEngine.test.js
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import protocols from '../src/content/protocols.json' with { type: 'json' };
+import {
+ evaluateNightProtocolSet,
+ generateNightProtocols,
+ protocolAppliesToShift,
+ evaluateProtocolDecision,
+} from '../src/protocolEngine.js';
+
+test('a night protocol set changes the shift decision only through applicable rules', () => {
+ const selected = [
+ protocols.find(item => item.id === 'floor_13_forbidden'),
+ protocols.find(item => item.id === 'cam07_delay_expected'),
+ ];
+ const shift = {
+ screenData: { floor: 13, passengers: 0, door: 'closed' },
+ panelData: { floor: 13, passengers: 0, door: 'closed' },
+ evidence: { cameraDelayMs: 2000 },
+ protocolTags: ['floor'],
+ };
+
+ const result = evaluateNightProtocolSet(selected, shift);
+
+ assert.equal(result.decision, 'lockdown');
+ assert.deepEqual(result.appliedProtocolIds, ['floor_13_forbidden']);
+ assert.deepEqual(result.violatedProtocolIds, ['floor_13_forbidden']);
+ assert.ok(result.verificationPaths.includes('cam07'));
+});
+
+test('protocol catalogue uses one-sentence player-readable rules', () => {
+ assert.ok(protocols.length >= 5);
+ for (const protocol of protocols) {
+ assert.match(protocol.id, /^[a-z0-9_]+$/);
+ assert.ok(['floor', 'personnel', 'time', 'device', 'identity'].includes(protocol.category));
+ assert.ok(protocol.text.length <= 44);
+ assert.equal(/[。!?]/g.test(protocol.text.slice(0, -1)), false, `${protocol.id} must be one sentence`);
+ }
+});
+
+test('night protocol generation returns 2-3 unique rules and guarantees one applicable rule', () => {
+ const shifts = [{ protocolTags: ['device'], evidence: { camera: 'cam07' } }];
+ const selected = generateNightProtocols({ protocols, shifts, count: 3, random: () => 0 });
+
+ assert.equal(selected.length, 3);
+ assert.equal(new Set(selected.map(item => item.id)).size, 3);
+ assert.ok(selected.some(protocol => shifts.some(shift => protocolAppliesToShift(protocol, shift))));
+});
+
+test('protocol-dependent decision is deterministic and includes a reliable verification path', () => {
+ const protocol = protocols.find(item => item.id === 'floor_13_forbidden');
+ const shift = {
+ screenData: { floor: 13, passengers: 0, door: 'closed' },
+ panelData: { floor: 13, passengers: 0, door: 'closed' },
+ protocolTags: ['floor'],
+ evidence: { cameras: ['cam01', 'cam07'], tools: ['protocol'] },
+ };
+
+ const result = evaluateProtocolDecision(protocol, shift);
+ assert.equal(result.decision, 'lockdown');
+ assert.equal(result.violated, true);
+ assert.ok(result.verificationPaths.length >= 1);
+});
diff --git a/tests/state.test.js b/tests/state.test.js
index caf3276..d48e513 100644
--- a/tests/state.test.js
+++ b/tests/state.test.js
@@ -41,6 +41,71 @@ test('cloneState creates a deep copy suitable for rollback snapshots', () => {
assert.notEqual(copy.logs, state.logs);
});
+test('initial state includes an isolated V5 night and investigation baseline', () => {
+ const first = createInitialState();
+ const second = createInitialState();
+
+ assert.deepEqual(first.night, {
+ activeProtocols: [],
+ currentShift: null,
+ roundType: 'quick',
+ shiftIndex: 0,
+ decisions: [],
+ eventChains: {},
+ eventChainFlags: [],
+ eventChainHistory: [],
+ timelineSequence: 0,
+ nextShiftModifiers: [],
+ });
+ assert.equal(first.investigation.power, first.power);
+ assert.equal(first.investigation.activeCamera, 'cam01');
+ assert.deepEqual(first.investigation.discoveredEvidence, []);
+ assert.equal(first.investigation.tools.thermal.remaining, 2);
+ assert.equal(first.investigation.tools.replay.remaining, 2);
+ assert.equal(first.investigation.tools.protocol.powerCost, 0);
+
+ first.night.activeProtocols.push({ id: 'protocol-mutated' });
+ first.investigation.tools.thermal.remaining = 0;
+ assert.deepEqual(second.night.activeProtocols, []);
+ assert.equal(second.investigation.tools.thermal.remaining, 2);
+});
+
+test('ad revive rolls back V5 night and investigation state as deep copies', () => {
+ const prepared = createInitialState();
+ prepared.elapsed = 20;
+ prepared.night.activeProtocols = [{ id: 'protocol-floor-13' }];
+ prepared.night.currentShift = { id: 'shift-07', evidence: { cameras: { cam01: [] } } };
+ prepared.night.roundType = 'investigation';
+ prepared.investigation.activeCamera = 'cam07';
+ prepared.investigation.tools.thermal.remaining = 1;
+ prepared.investigation.discoveredEvidence.push({ id: 'evidence-before-failure' });
+
+ const snapshotted = saveSnapshot(prepared);
+ const failed = {
+ ...snapshotted,
+ elapsed: 49,
+ gameOver: true,
+ result: 'failure',
+ anomalyLevel: CONFIG.failure.anomalyLevelMax,
+ };
+ failed.night.currentShift.evidence.cameras.cam01.push({ id: 'late-evidence' });
+ failed.investigation.discoveredEvidence.push({ id: 'late-discovery' });
+
+ const revived = reviveFromAd(failed);
+
+ assert.equal(revived.night.roundType, 'investigation');
+ assert.equal(revived.night.currentShift.id, 'shift-07');
+ assert.deepEqual(revived.night.currentShift.evidence.cameras.cam01, []);
+ assert.equal(revived.investigation.activeCamera, 'cam07');
+ assert.equal(revived.investigation.tools.thermal.remaining, 1);
+ assert.deepEqual(revived.investigation.discoveredEvidence, [{ id: 'evidence-before-failure' }]);
+
+ revived.night.activeProtocols[0].id = 'mutated-after-revive';
+ revived.investigation.discoveredEvidence[0].id = 'mutated-after-revive';
+ assert.equal(failed.snapshots[0].state.night.activeProtocols[0].id, 'protocol-floor-13');
+ assert.equal(failed.snapshots[0].state.investigation.discoveredEvidence[0].id, 'evidence-before-failure');
+});
+
test('saveSnapshot appends a deep copy without nesting snapshots inside the saved state', () => {
const state = { ...createInitialState(), floor: 5, power: 80, elapsed: 30, snapshots: [] };
const next = saveSnapshot(state);
diff --git a/tests/v5ContentPhase2.test.js b/tests/v5ContentPhase2.test.js
new file mode 100644
index 0000000..3badbc0
--- /dev/null
+++ b/tests/v5ContentPhase2.test.js
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+
+const readJson = path => JSON.parse(readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'));
+const anomalies = readJson('src/content/anomalies.json');
+const normalShifts = readJson('src/content/normalShifts.json');
+const passengers = readJson('src/content/passengers.json');
+const eventChains = readJson('src/content/eventChains.json');
+
+const categories = ['person', 'count', 'space', 'time', 'device', 'dynamic'];
+
+test('Phase 2 ships exactly 30 anomalies split evenly across six categories', () => {
+ assert.equal(anomalies.length, 30);
+ for (const category of categories) {
+ assert.equal(anomalies.filter(item => item.category === category).length, 5, category);
+ }
+ assert.equal(new Set(anomalies.map(item => item.id)).size, 30);
+});
+
+test('every V5 anomaly has at least two non-audio verification paths', () => {
+ for (const anomaly of anomalies) {
+ const paths = new Set(anomaly.silentEvidence);
+ assert.ok(paths.size >= 2, anomaly.id);
+ assert.equal(anomaly.availableTools.includes('camera'), true, anomaly.id);
+ assert.ok(['quick', 'investigation', 'identity', 'highRisk'].includes(anomaly.roundType), anomaly.id);
+ }
+});
+
+test('Phase 2 ships ten consistent normal shifts and five passenger identities', () => {
+ assert.equal(normalShifts.length, 10);
+ assert.equal(passengers.length, 5);
+ for (const shift of normalShifts) assert.deepEqual(shift.screenData, shift.panelData, shift.id);
+ for (const passenger of passengers) {
+ assert.ok(passenger.id && passenger.name && passenger.role);
+ assert.ok(Array.isArray(passenger.allowedFloors) && passenger.allowedFloors.length > 0);
+ }
+});
+
+test('all Phase 2 content references resolve to existing records', () => {
+ const anomalyIds = new Set(anomalies.map(item => item.id));
+ const normalIds = new Set(normalShifts.map(item => item.id));
+ const passengerIds = new Set(passengers.map(item => item.id));
+
+ for (const anomaly of anomalies) {
+ for (const normalId of anomaly.normalVariants) assert.ok(normalIds.has(normalId), `${anomaly.id} -> ${normalId}`);
+ }
+ for (const shift of normalShifts) {
+ for (const passengerId of shift.passengerIds) assert.ok(passengerIds.has(passengerId), `${shift.id} -> ${passengerId}`);
+ }
+ for (const chain of eventChains) {
+ for (const step of chain.steps) {
+ assert.ok(anomalyIds.has(step.contentId) || normalIds.has(step.contentId), `${chain.id} -> ${step.contentId}`);
+ }
+ }
+});
+
+test('Phase 2 ships three named three-step event chains', () => {
+ assert.deepEqual(eventChains.map(item => item.id), [
+ 'duplicate_passenger',
+ 'nonexistent_floor',
+ 'camera_replacement',
+ ]);
+ for (const chain of eventChains) assert.equal(chain.steps.length, 3, chain.id);
+});
diff --git a/tests/v5ScreenshotAcceptance.test.js b/tests/v5ScreenshotAcceptance.test.js
new file mode 100644
index 0000000..0ce083e
--- /dev/null
+++ b/tests/v5ScreenshotAcceptance.test.js
@@ -0,0 +1,34 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const root = resolve(import.meta.dirname, '..');
+
+function readPngSize(path) {
+ const png = readFileSync(path);
+ assert.equal(png.subarray(1, 4).toString('ascii'), 'PNG');
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
+}
+
+test('V5 acceptance harness and official portrait runtime captures are checked in', () => {
+ const harness = resolve(root, 'scripts', 'v5-canvas-acceptance.html');
+ assert.ok(existsSync(harness), 'production Canvas acceptance harness must exist');
+
+ for (const [name, expected] of [
+ ['v5-runtime-393x852.png', { width: 393, height: 852 }],
+ ['v5-runtime-360x640.png', { width: 360, height: 640 }],
+ ['v5-runtime-identity-393x852.png', { width: 393, height: 852 }],
+ ['v5-runtime-identity-360x640.png', { width: 360, height: 640 }],
+ ['v5-runtime-high-risk-393x852.png', { width: 393, height: 852 }],
+ ['v5-runtime-high-risk-360x640.png', { width: 360, height: 640 }],
+ ['v5-runtime-protocol-query-393x852.png', { width: 393, height: 852 }],
+ ['v5-runtime-protocol-query-360x640.png', { width: 360, height: 640 }],
+ ['v5-runtime-debrief-393x852.png', { width: 393, height: 852 }],
+ ['v5-runtime-debrief-360x640.png', { width: 360, height: 640 }],
+ ]) {
+ const capture = resolve(root, 'docs', 'screenshots', name);
+ assert.ok(existsSync(capture), `${name} must be captured from the runtime renderer`);
+ assert.deepEqual(readPngSize(capture), expected);
+ }
+});
diff --git a/wechat-minigame/audio/bgm-anomaly-pressure-loop.wav b/wechat-minigame/audio/bgm-anomaly-pressure-loop.wav
new file mode 100644
index 0000000..4c18e4a
Binary files /dev/null and b/wechat-minigame/audio/bgm-anomaly-pressure-loop.wav differ
diff --git a/wechat-minigame/audio/bgm-night-shift-loop.wav b/wechat-minigame/audio/bgm-night-shift-loop.wav
new file mode 100644
index 0000000..6ad7666
Binary files /dev/null and b/wechat-minigame/audio/bgm-night-shift-loop.wav differ
diff --git a/wechat-minigame/audio/lockdown.wav b/wechat-minigame/audio/lockdown.wav
index c8c3ba1..52aa9b6 100644
Binary files a/wechat-minigame/audio/lockdown.wav and b/wechat-minigame/audio/lockdown.wav differ
diff --git a/wechat-minigame/game.js b/wechat-minigame/game.js
index b485a32..11d7376 100644
--- a/wechat-minigame/game.js
+++ b/wechat-minigame/game.js
@@ -6,7 +6,12 @@
(function() {
'use strict';
+// --- V5 content (deterministic) ---
+var __V5_CONTENT__ = {"anomalies":[{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"duplicate_face","contradicts":true,"id":"duplicate_face_cam01","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam01"}],"cam03":[{"conflictKey":"duplicate_face","contradicts":true,"id":"duplicate_face_cam03","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam03"}],"cam07":[{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_cam07","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"cam07"}]},"replay":{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_replay","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"replay"},"thermal":{"conflictKey":"duplicate_face","contradicts":false,"id":"duplicate_face_thermal","observation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","source":"thermal"}},"explanation":"同一乘客在 CAM-01 出现两次,但 CAM-03 只有一次进入记录。","highRisk":false,"id":"person_duplicate_face","name":"重复面孔","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"duplicate_face","protocolDependent":false,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"wrong_badge","contradicts":true,"id":"wrong_badge_cam01","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam01"}],"cam03":[{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_cam03","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam03"}],"cam07":[{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_cam07","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"cam07"}]},"replay":{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_replay","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"replay"},"thermal":{"conflictKey":"wrong_badge","contradicts":false,"id":"wrong_badge_thermal","observation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","source":"thermal"}},"explanation":"自称维修员的人佩戴红色胸牌,不符合黄色胸牌协议。","highRisk":false,"id":"person_wrong_badge","name":"错误胸牌","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"wrong_badge","protocolDependent":true,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"cold_passenger","contradicts":true,"id":"cold_passenger_cam01","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam01"}],"cam03":[{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_cam03","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam03"}],"cam07":[{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_cam07","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"cam07"}]},"replay":{"conflictKey":"cold_passenger","contradicts":false,"id":"cold_passenger_replay","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"replay"},"thermal":{"conflictKey":"cold_passenger","contradicts":true,"id":"cold_passenger_thermal","observation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","source":"thermal"}},"explanation":"画面中乘客轮廓清晰,但热源扫描没有生命反应。","highRisk":false,"id":"person_cold_passenger","name":"无热源人物","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"cold_passenger","protocolDependent":false,"protocolTags":["person"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","thermal"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"unknown_identity","contradicts":true,"id":"unknown_identity_cam01","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam01"}],"cam03":[{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_cam03","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam03"}],"cam07":[{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_cam07","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"cam07"}]},"replay":{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_replay","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"replay"},"thermal":{"conflictKey":"unknown_identity","contradicts":false,"id":"unknown_identity_thermal","observation":"乘客工号不在当夜授权名单,目标楼层却被请求。","source":"thermal"}},"explanation":"乘客工号不在当夜授权名单,目标楼层却被请求。","highRisk":false,"id":"person_unknown_identity","name":"不存在的工号","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"unknown_identity","protocolDependent":true,"protocolTags":["person","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"person","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"shadow_mismatch","contradicts":true,"id":"shadow_mismatch_cam01","observation":"一名乘客对应两道独立移动的影子。","source":"cam01"}],"cam03":[{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_cam03","observation":"一名乘客对应两道独立移动的影子。","source":"cam03"}],"cam07":[{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_cam07","observation":"一名乘客对应两道独立移动的影子。","source":"cam07"}]},"replay":{"conflictKey":"shadow_mismatch","contradicts":true,"id":"shadow_mismatch_replay","observation":"一名乘客对应两道独立移动的影子。","source":"replay"},"thermal":{"conflictKey":"shadow_mismatch","contradicts":false,"id":"shadow_mismatch_thermal","observation":"一名乘客对应两道独立移动的影子。","source":"thermal"}},"explanation":"一名乘客对应两道独立移动的影子。","highRisk":false,"id":"person_shadow_mismatch","name":"影子人数异常","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"shadow_mismatch","protocolDependent":false,"protocolTags":["person"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"panel_undercount","contradicts":true,"id":"panel_undercount_cam01","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam01"}],"cam03":[{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_cam03","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam03"}],"cam07":[{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_cam07","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"cam07"}]},"replay":{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_replay","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"replay"},"thermal":{"conflictKey":"panel_undercount","contradicts":false,"id":"panel_undercount_thermal","observation":"CAM-01 可见两名乘客,主控只记录一人。","source":"thermal"}},"explanation":"CAM-01 可见两名乘客,主控只记录一人。","highRisk":false,"id":"count_panel_undercount","name":"主控少计一人","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":7,"passengers":2},"primaryConflict":"panel_undercount","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam01","panel"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"empty_weight","contradicts":true,"id":"empty_weight_cam01","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam01"}],"cam03":[{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_cam03","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam03"}],"cam07":[{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_cam07","observation":"轿厢无人,但载重连续两次记录为一人。","source":"cam07"}]},"replay":{"conflictKey":"empty_weight","contradicts":true,"id":"empty_weight_replay","observation":"轿厢无人,但载重连续两次记录为一人。","source":"replay"},"thermal":{"conflictKey":"empty_weight","contradicts":false,"id":"empty_weight_thermal","observation":"轿厢无人,但载重连续两次记录为一人。","source":"thermal"}},"explanation":"轿厢无人,但载重连续两次记录为一人。","highRisk":false,"id":"count_empty_weight","name":"空厢载重","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":8,"passengers":0},"primaryConflict":"empty_weight","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"maintenance_counted","contradicts":true,"id":"maintenance_counted_cam01","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam01"}],"cam03":[{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_cam03","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam03"}],"cam07":[{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_cam07","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"cam07"}]},"replay":{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_replay","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"replay"},"thermal":{"conflictKey":"maintenance_counted","contradicts":false,"id":"maintenance_counted_thermal","observation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","source":"thermal"}},"explanation":"黄色胸牌维修员按协议应忽略,主控却计入人数。","highRisk":false,"id":"count_maintenance_counted","name":"维修员计数错误","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":9,"passengers":2},"primaryConflict":"maintenance_counted","protocolDependent":true,"protocolTags":["count","identity"],"resolutionAction":"lockdown","roundType":"identity","screenData":{"door":"closed","floor":9,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"reflection_count","contradicts":true,"id":"reflection_count_cam01","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam01"}],"cam03":[{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_cam03","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam03"}],"cam07":[{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_cam07","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"cam07"}]},"replay":{"conflictKey":"reflection_count","contradicts":true,"id":"reflection_count_replay","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"replay"},"thermal":{"conflictKey":"reflection_count","contradicts":false,"id":"reflection_count_thermal","observation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","source":"thermal"}},"explanation":"镜面中的人影动作与乘客不同步,人数传感器多计一人。","highRisk":false,"id":"count_reflection_count","name":"倒影独立计数","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":10,"passengers":0},"primaryConflict":"reflection_count","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":10,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"count","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":1,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_cam01","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam01"}],"cam03":[{"conflictKey":"exit_without_decrement","contradicts":true,"id":"exit_without_decrement_cam03","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam03"}],"cam07":[{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_cam07","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"cam07"}]},"replay":{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_replay","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"replay"},"thermal":{"conflictKey":"exit_without_decrement","contradicts":false,"id":"exit_without_decrement_thermal","observation":"CAM-03 显示乘客离开,主控人数仍未减少。","source":"thermal"}},"explanation":"CAM-03 显示乘客离开,主控人数仍未减少。","highRisk":false,"id":"count_exit_without_decrement","name":"离开后未减员","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":11,"passengers":2},"primaryConflict":"exit_without_decrement","protocolDependent":false,"protocolTags":["count"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":11,"passengers":1},"silentEvidence":["cam03","panel"],"visualState":"14_duplicate_subject"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_cam01","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam01"}],"cam03":[{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_cam03","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam03"}],"cam07":[{"conflictKey":"floor_13","contradicts":true,"id":"floor_13_cam07","observation":"楼层请求指向协议中不存在的 13 层。","source":"cam07"}]},"replay":{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_replay","observation":"楼层请求指向协议中不存在的 13 层。","source":"replay"},"thermal":{"conflictKey":"floor_13","contradicts":false,"id":"floor_13_thermal","observation":"楼层请求指向协议中不存在的 13 层。","source":"thermal"}},"explanation":"楼层请求指向协议中不存在的 13 层。","highRisk":true,"id":"space_floor_13","name":"不存在楼层","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"closed","floor":13,"passengers":1},"primaryConflict":"floor_13","protocolDependent":true,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":13,"passengers":1},"silentEvidence":["cam07","protocol"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_cam01","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam01"}],"cam03":[{"conflictKey":"wrong_corridor","contradicts":true,"id":"wrong_corridor_cam03","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam03"}],"cam07":[{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_cam07","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"cam07"}]},"replay":{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_replay","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"replay"},"thermal":{"conflictKey":"wrong_corridor","contradicts":false,"id":"wrong_corridor_thermal","observation":"CAM-03 显示的走廊结构与目标楼层档案不符。","source":"thermal"}},"explanation":"CAM-03 显示的走廊结构与目标楼层档案不符。","highRisk":false,"id":"space_wrong_corridor","name":"错误走廊","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":1,"passengers":1},"primaryConflict":"wrong_corridor","protocolDependent":true,"protocolTags":["space"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":1,"passengers":1},"silentEvidence":["cam03","protocol"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"simultaneous_cameras","contradicts":true,"id":"simultaneous_cameras_cam01","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam01"}],"cam03":[{"conflictKey":"simultaneous_cameras","contradicts":true,"id":"simultaneous_cameras_cam03","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam03"}],"cam07":[{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_cam07","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"cam07"}]},"replay":{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_replay","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"replay"},"thermal":{"conflictKey":"simultaneous_cameras","contradicts":false,"id":"simultaneous_cameras_thermal","observation":"同一乘客同时出现在 CAM-01 与 CAM-03。","source":"thermal"}},"explanation":"同一乘客同时出现在 CAM-01 与 CAM-03。","highRisk":true,"id":"space_simultaneous_cameras","name":"双处出现","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"simultaneous_cameras","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_cam01","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam01"}],"cam03":[{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_cam03","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam03"}],"cam07":[{"conflictKey":"shaft_entry","contradicts":true,"id":"shaft_entry_cam07","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"cam07"}]},"replay":{"conflictKey":"shaft_entry","contradicts":true,"id":"shaft_entry_replay","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"replay"},"thermal":{"conflictKey":"shaft_entry","contradicts":false,"id":"shaft_entry_thermal","observation":"CAM-07 记录到乘客在轿厢到达前进入井道。","source":"thermal"}},"explanation":"CAM-07 记录到乘客在轿厢到达前进入井道。","highRisk":true,"id":"space_shaft_entry","name":"井道提前进入","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"shaft_entry","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"space","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_to_wall","contradicts":true,"id":"door_to_wall_cam01","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam01"}],"cam03":[{"conflictKey":"door_to_wall","contradicts":true,"id":"door_to_wall_cam03","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam03"}],"cam07":[{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_cam07","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"cam07"}]},"replay":{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_replay","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"replay"},"thermal":{"conflictKey":"door_to_wall","contradicts":false,"id":"door_to_wall_thermal","observation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","source":"thermal"}},"explanation":"开门后出现封闭墙面,而主控仍报告楼层走廊。","highRisk":false,"id":"space_door_to_wall","name":"门后墙体","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"door_to_wall","protocolDependent":false,"protocolTags":["space"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","cam03"],"visualState":"16_wrong_floor"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"motion_loop","contradicts":true,"id":"motion_loop_cam01","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam01"}],"cam03":[{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_cam03","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam03"}],"cam07":[{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_cam07","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"cam07"}]},"replay":{"conflictKey":"motion_loop","contradicts":true,"id":"motion_loop_replay","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"replay"},"thermal":{"conflictKey":"motion_loop","contradicts":false,"id":"motion_loop_thermal","observation":"三秒回放显示乘客动作逐帧完全重复。","source":"thermal"}},"explanation":"三秒回放显示乘客动作逐帧完全重复。","highRisk":false,"id":"time_motion_loop","name":"动作循环","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"motion_loop","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_cam01","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam01"}],"cam03":[{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_cam03","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam03"}],"cam07":[{"conflictKey":"clock_stall","contradicts":true,"id":"clock_stall_cam07","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"cam07"}]},"replay":{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_replay","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"replay"},"thermal":{"conflictKey":"clock_stall","contradicts":false,"id":"clock_stall_thermal","observation":"CAM-07 时间码停止,但主控时钟继续前进。","source":"thermal"}},"explanation":"CAM-07 时间码停止,但主控时钟继续前进。","highRisk":false,"id":"time_clock_stall","name":"时间停止","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"clock_stall","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam07","panel"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_cam01","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam01"}],"cam03":[{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_cam03","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam03"}],"cam07":[{"conflictKey":"early_arrival","contradicts":true,"id":"early_arrival_cam07","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"cam07"}]},"replay":{"conflictKey":"early_arrival","contradicts":true,"id":"early_arrival_replay","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"replay"},"thermal":{"conflictKey":"early_arrival","contradicts":false,"id":"early_arrival_thermal","observation":"井道记录显示轿厢在调度命令前已经到站。","source":"thermal"}},"explanation":"井道记录显示轿厢在调度命令前已经到站。","highRisk":false,"id":"time_early_arrival","name":"提前到达","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":7,"passengers":1},"primaryConflict":"early_arrival","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_cam01","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam01"}],"cam03":[{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_cam03","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam03"}],"cam07":[{"conflictKey":"delay_overrun","contradicts":true,"id":"delay_overrun_cam07","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"cam07"}]},"replay":{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_replay","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"replay"},"thermal":{"conflictKey":"delay_overrun","contradicts":false,"id":"delay_overrun_thermal","observation":"CAM-07 延迟超过协议允许的固定两秒。","source":"thermal"}},"explanation":"CAM-07 延迟超过协议允许的固定两秒。","highRisk":false,"id":"time_delay_overrun","name":"延迟超限","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":8,"passengers":1},"primaryConflict":"delay_overrun","protocolDependent":true,"protocolTags":["time"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1},"silentEvidence":["cam07","protocol"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"time","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":2,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"future_frame","contradicts":true,"id":"future_frame_cam01","observation":"回放中出现三秒后才发生的开门动作。","source":"cam01"}],"cam03":[{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_cam03","observation":"回放中出现三秒后才发生的开门动作。","source":"cam03"}],"cam07":[{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_cam07","observation":"回放中出现三秒后才发生的开门动作。","source":"cam07"}]},"replay":{"conflictKey":"future_frame","contradicts":true,"id":"future_frame_replay","observation":"回放中出现三秒后才发生的开门动作。","source":"replay"},"thermal":{"conflictKey":"future_frame","contradicts":false,"id":"future_frame_thermal","observation":"回放中出现三秒后才发生的开门动作。","source":"thermal"}},"explanation":"回放中出现三秒后才发生的开门动作。","highRisk":true,"id":"time_future_frame","name":"未来帧","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":9,"passengers":1},"primaryConflict":"future_frame","protocolDependent":false,"protocolTags":["time"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":9,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"11_camera_glitch"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_state","contradicts":true,"id":"door_state_cam01","observation":"画面中门已开启,主控仍报告关闭。","source":"cam01"}],"cam03":[{"conflictKey":"door_state","contradicts":false,"id":"door_state_cam03","observation":"画面中门已开启,主控仍报告关闭。","source":"cam03"}],"cam07":[{"conflictKey":"door_state","contradicts":false,"id":"door_state_cam07","observation":"画面中门已开启,主控仍报告关闭。","source":"cam07"}]},"replay":{"conflictKey":"door_state","contradicts":false,"id":"door_state_replay","observation":"画面中门已开启,主控仍报告关闭。","source":"replay"},"thermal":{"conflictKey":"door_state","contradicts":false,"id":"door_state_thermal","observation":"画面中门已开启,主控仍报告关闭。","source":"thermal"}},"explanation":"画面中门已开启,主控仍报告关闭。","highRisk":false,"id":"device_door_state","name":"门状态冲突","normalVariants":["normal_shift_01","normal_shift_02"],"panelData":{"door":"open","floor":10,"passengers":1},"primaryConflict":"door_state","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":10,"passengers":1},"silentEvidence":["cam01","panel"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":8,"evidence":{"cameras":{"cam01":[{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_cam01","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam01"}],"cam03":[{"conflictKey":"floor_sensor","contradicts":true,"id":"floor_sensor_cam03","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam03"}],"cam07":[{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_cam07","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"cam07"}]},"replay":{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_replay","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"replay"},"thermal":{"conflictKey":"floor_sensor","contradicts":false,"id":"floor_sensor_thermal","observation":"CAM-03 楼层标识与主控楼层传感器不一致。","source":"thermal"}},"explanation":"CAM-03 楼层标识与主控楼层传感器不一致。","highRisk":false,"id":"device_floor_sensor","name":"楼层传感错误","normalVariants":["normal_shift_02","normal_shift_03"],"panelData":{"door":"closed","floor":11,"passengers":1},"primaryConflict":"floor_sensor","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"quick","screenData":{"door":"closed","floor":11,"passengers":1},"silentEvidence":["cam03","panel"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_cam01","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam01"}],"cam03":[{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_cam03","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam03"}],"cam07":[{"conflictKey":"camera_substitution","contradicts":true,"id":"camera_substitution_cam07","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"cam07"}]},"replay":{"conflictKey":"camera_substitution","contradicts":true,"id":"camera_substitution_replay","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"replay"},"thermal":{"conflictKey":"camera_substitution","contradicts":false,"id":"camera_substitution_thermal","observation":"CAM-07 时间码变化但画面像素完全不变。","source":"thermal"}},"explanation":"CAM-07 时间码变化但画面像素完全不变。","highRisk":true,"id":"device_camera_substitution","name":"画面被替换","normalVariants":["normal_shift_03","normal_shift_04"],"panelData":{"door":"closed","floor":12,"passengers":1},"primaryConflict":"camera_substitution","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":12,"passengers":1},"silentEvidence":["cam07","replay"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"thermal_ghost","contradicts":true,"id":"thermal_ghost_cam01","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam01"}],"cam03":[{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_cam03","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam03"}],"cam07":[{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_cam07","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"cam07"}]},"replay":{"conflictKey":"thermal_ghost","contradicts":false,"id":"thermal_ghost_replay","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"replay"},"thermal":{"conflictKey":"thermal_ghost","contradicts":true,"id":"thermal_ghost_thermal","observation":"空轿厢出现移动热源,三台摄像头均无人。","source":"thermal"}},"explanation":"空轿厢出现移动热源,三台摄像头均无人。","highRisk":false,"id":"device_thermal_ghost","name":"虚假热源","normalVariants":["normal_shift_04","normal_shift_05"],"panelData":{"door":"closed","floor":1,"passengers":1},"primaryConflict":"thermal_ghost","protocolDependent":false,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":1,"passengers":1},"silentEvidence":["thermal","cam01"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"device","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_cycle","contradicts":true,"id":"door_cycle_cam01","observation":"维护状态下门循环超过协议允许的一次。","source":"cam01"}],"cam03":[{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_cam03","observation":"维护状态下门循环超过协议允许的一次。","source":"cam03"}],"cam07":[{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_cam07","observation":"维护状态下门循环超过协议允许的一次。","source":"cam07"}]},"replay":{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_replay","observation":"维护状态下门循环超过协议允许的一次。","source":"replay"},"thermal":{"conflictKey":"door_cycle","contradicts":false,"id":"door_cycle_thermal","observation":"维护状态下门循环超过协议允许的一次。","source":"thermal"}},"explanation":"维护状态下门循环超过协议允许的一次。","highRisk":false,"id":"device_door_cycle","name":"门循环超限","normalVariants":["normal_shift_05","normal_shift_06"],"panelData":{"door":"closed","floor":2,"passengers":1},"primaryConflict":"door_cycle","protocolDependent":true,"protocolTags":["device"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":2,"passengers":1},"silentEvidence":["cam01","protocol"],"visualState":"09_door_jammed"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"instant_shift","contradicts":true,"id":"instant_shift_cam01","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam01"}],"cam03":[{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_cam03","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam03"}],"cam07":[{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_cam07","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"cam07"}]},"replay":{"conflictKey":"instant_shift","contradicts":true,"id":"instant_shift_replay","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"replay"},"thermal":{"conflictKey":"instant_shift","contradicts":false,"id":"instant_shift_thermal","observation":"乘客在相邻帧从轿厢左侧瞬移到门外。","source":"thermal"}},"explanation":"乘客在相邻帧从轿厢左侧瞬移到门外。","highRisk":false,"id":"dynamic_instant_shift","name":"人物瞬移","normalVariants":["normal_shift_06","normal_shift_07"],"panelData":{"door":"closed","floor":3,"passengers":1},"primaryConflict":"instant_shift","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":3,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"delayed_shadow","contradicts":true,"id":"delayed_shadow_cam01","observation":"乘客停止后影子仍继续移动两秒。","source":"cam01"}],"cam03":[{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_cam03","observation":"乘客停止后影子仍继续移动两秒。","source":"cam03"}],"cam07":[{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_cam07","observation":"乘客停止后影子仍继续移动两秒。","source":"cam07"}]},"replay":{"conflictKey":"delayed_shadow","contradicts":true,"id":"delayed_shadow_replay","observation":"乘客停止后影子仍继续移动两秒。","source":"replay"},"thermal":{"conflictKey":"delayed_shadow","contradicts":false,"id":"delayed_shadow_thermal","observation":"乘客停止后影子仍继续移动两秒。","source":"thermal"}},"explanation":"乘客停止后影子仍继续移动两秒。","highRisk":false,"id":"dynamic_delayed_shadow","name":"影子延迟","normalVariants":["normal_shift_07","normal_shift_08"],"panelData":{"door":"closed","floor":4,"passengers":1},"primaryConflict":"delayed_shadow","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":4,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"reverse_walk","contradicts":true,"id":"reverse_walk_cam01","observation":"乘客向前行走但位置持续向后移动。","source":"cam01"}],"cam03":[{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_cam03","observation":"乘客向前行走但位置持续向后移动。","source":"cam03"}],"cam07":[{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_cam07","observation":"乘客向前行走但位置持续向后移动。","source":"cam07"}]},"replay":{"conflictKey":"reverse_walk","contradicts":true,"id":"reverse_walk_replay","observation":"乘客向前行走但位置持续向后移动。","source":"replay"},"thermal":{"conflictKey":"reverse_walk","contradicts":false,"id":"reverse_walk_thermal","observation":"乘客向前行走但位置持续向后移动。","source":"thermal"}},"explanation":"乘客向前行走但位置持续向后移动。","highRisk":false,"id":"dynamic_reverse_walk","name":"逆向动作","normalVariants":["normal_shift_08","normal_shift_09"],"panelData":{"door":"closed","floor":5,"passengers":1},"primaryConflict":"reverse_walk","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":6},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"frozen_passenger","contradicts":true,"id":"frozen_passenger_cam01","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam01"}],"cam03":[{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_cam03","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam03"}],"cam07":[{"conflictKey":"frozen_passenger","contradicts":true,"id":"frozen_passenger_cam07","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"cam07"}]},"replay":{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_replay","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"replay"},"thermal":{"conflictKey":"frozen_passenger","contradicts":false,"id":"frozen_passenger_thermal","observation":"轿厢震动时乘客轮廓保持像素级静止。","source":"thermal"}},"explanation":"轿厢震动时乘客轮廓保持像素级静止。","highRisk":false,"id":"dynamic_frozen_passenger","name":"局部静止","normalVariants":["normal_shift_09","normal_shift_10"],"panelData":{"door":"closed","floor":6,"passengers":1},"primaryConflict":"frozen_passenger","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown","roundType":"investigation","screenData":{"door":"closed","floor":6,"passengers":1},"silentEvidence":["cam01","cam07"],"visualState":"13_entity_near"},{"audioCue":null,"availableTools":["camera","thermal","replay","protocol"],"category":"dynamic","contaminationEffects":{"onCorrect":-2,"onMiss":12},"decision":"anomaly","difficulty":3,"duration":14,"evidence":{"cameras":{"cam01":[{"conflictKey":"door_crossing","contradicts":true,"id":"door_crossing_cam01","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam01"}],"cam03":[{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_cam03","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam03"}],"cam07":[{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_cam07","observation":"门关闭期间人物轮廓穿过实体门板。","source":"cam07"}]},"replay":{"conflictKey":"door_crossing","contradicts":true,"id":"door_crossing_replay","observation":"门关闭期间人物轮廓穿过实体门板。","source":"replay"},"thermal":{"conflictKey":"door_crossing","contradicts":false,"id":"door_crossing_thermal","observation":"门关闭期间人物轮廓穿过实体门板。","source":"thermal"}},"explanation":"门关闭期间人物轮廓穿过实体门板。","highRisk":true,"id":"dynamic_door_crossing","name":"穿门而过","normalVariants":["normal_shift_10","normal_shift_01"],"panelData":{"door":"closed","floor":7,"passengers":1},"primaryConflict":"door_crossing","protocolDependent":false,"protocolTags":["dynamic"],"resolutionAction":"lockdown_floor","roundType":"highRisk","screenData":{"door":"closed","floor":7,"passengers":1},"silentEvidence":["cam01","replay"],"visualState":"13_entity_near"}],"endings":[{"conditions":{"requiredFlag":"camera_chain_compromised"},"id":"camera_taken","name":"替换信号","priority":5,"summary":"你完成了值守,但 CAM-07 留下的已不是今晚的画面。"},{"conditions":{"requiredFlag":"floor_chain_compromised"},"id":"floor_consumed","name":"第十三层","priority":5,"summary":"主控恢复正常,楼层表却从此多出一个无法删除的编号。"},{"conditions":{"minContamination":76},"id":"contaminated_survivor","name":"带回来的夜班","priority":3,"summary":"你活过了夜班,但系统污染已经跟随档案进入下一次值守。"},{"conditions":{"maxContamination":25,"minAccuracy":0.8},"id":"clean_shift","name":"清醒交班","priority":1,"summary":"每次决定都有证据支撑。晨班接管时,所有摄像头仍可信。"},{"conditions":{},"id":"uncertain_shift","name":"未决记录","priority":0,"summary":"你完成了交班,但有几段记录无法证明究竟发生过什么。"}],"eventChains":[{"consequences":[{"contaminationDelta":18,"flag":"chain_compromised","nextShiftModifier":"duplicate_feed"}],"id":"duplicate_passenger","initialFlags":[],"steps":[{"contentId":"normal_shift_02","id":"first_visit","onWrongFlags":["trusted_duplicate"],"roundType":"identity","trigger":"first_duplicate_candidate"},{"contentId":"person_duplicate_face","id":"repeated_motion","onWrongFlags":["motion_ignored"],"roundType":"investigation","trigger":"trusted_duplicate_or_next_shift"},{"contentId":"space_simultaneous_cameras","id":"simultaneous_presence","onWrongFlags":["chain_compromised"],"roundType":"highRisk","trigger":"second_duplicate_seen"}]},{"consequences":[{"contaminationDelta":22,"flag":"floor_chain_compromised","nextShiftModifier":"floor_13_bleed"}],"id":"nonexistent_floor","initialFlags":[],"steps":[{"contentId":"device_floor_sensor","id":"floor_flash","onWrongFlags":["floor_flash_ignored"],"roundType":"quick","trigger":"floor_display_flash"},{"contentId":"person_unknown_identity","id":"passenger_request","onWrongFlags":["invalid_request_allowed"],"roundType":"identity","trigger":"floor_flash_ignored_or_next_shift"},{"contentId":"space_floor_13","id":"impossible_space","onWrongFlags":["floor_chain_compromised"],"roundType":"highRisk","trigger":"invalid_request_allowed_or_escalation"}]},{"consequences":[{"contaminationDelta":20,"flag":"camera_chain_compromised","nextShiftModifier":"unreliable_cam07"}],"id":"camera_replacement","initialFlags":[],"steps":[{"contentId":"time_delay_overrun","id":"cam07_delay","onWrongFlags":["delay_accepted"],"roundType":"investigation","trigger":"cam07_delay"},{"contentId":"time_clock_stall","id":"time_stops","onWrongFlags":["clock_stop_ignored"],"roundType":"investigation","trigger":"delay_accepted_or_next_shift"},{"contentId":"device_camera_substitution","id":"feed_replaced","onWrongFlags":["camera_chain_compromised"],"roundType":"highRisk","trigger":"clock_stop_ignored_or_escalation"}]}],"normalShifts":[{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_01_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_01_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_01_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_01","panelData":{"door":"closed","floor":2,"passengers":1},"passengerIds":["resident_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":2,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_02_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_02_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_02_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_02","panelData":{"door":"closed","floor":3,"passengers":1},"passengerIds":["worker_001"],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":3,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_03_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_03_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_03_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_03","panelData":{"door":"open","floor":4,"passengers":0},"passengerIds":[],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":4,"passengers":0}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_04_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_04_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_04_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_04","panelData":{"door":"closed","floor":5,"passengers":1},"passengerIds":["cleaner_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":5,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_05_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_05_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_05_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_05","panelData":{"door":"closed","floor":6,"passengers":1},"passengerIds":["security_001"],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":6,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_06_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_06_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_06_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_06","panelData":{"door":"open","floor":7,"passengers":1},"passengerIds":["resident_001"],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":7,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_07_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_07_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_07_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_07","panelData":{"door":"closed","floor":8,"passengers":1},"passengerIds":["worker_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":8,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_08_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_08_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_08_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_08","panelData":{"door":"closed","floor":9,"passengers":0},"passengerIds":[],"protocolTags":["personnel"],"roundType":"identity","screenData":{"door":"closed","floor":9,"passengers":0}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_09_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_09_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_09_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_09","panelData":{"door":"open","floor":10,"passengers":1},"passengerIds":["cleaner_001"],"protocolTags":["device"],"roundType":"quick","screenData":{"door":"open","floor":10,"passengers":1}},{"evidence":{"cameras":{"cam01":[{"contradicts":false,"id":"normal_shift_10_cam01","observation":"画面与主控一致。","source":"cam01"}],"cam03":[{"contradicts":false,"id":"normal_shift_10_cam03","observation":"进出记录一致。","source":"cam03"}],"cam07":[{"contradicts":false,"id":"normal_shift_10_cam07","observation":"井道时序正常。","source":"cam07"}]}},"id":"normal_shift_10","panelData":{"door":"closed","floor":11,"passengers":1},"passengerIds":["security_001"],"protocolTags":["personnel"],"roundType":"investigation","screenData":{"door":"closed","floor":11,"passengers":1}}],"passengers":[{"allowedFloors":["B2","8"],"badge":"yellow","countMode":"ignore","id":"worker_001","name":"张伟","role":"maintenance","verificationPaths":["cam01","protocol"]},{"allowedFloors":["3","6","9"],"badge":"blue","countMode":"normal","id":"resident_001","name":"林岚","role":"resident","verificationPaths":["cam01","registry"]},{"allowedFloors":["1","2","3"],"badge":"orange","countMode":"normal","id":"courier_001","name":"陈杰","role":"courier","verificationPaths":["cam03","registry"]},{"allowedFloors":["2","4","6","8","10","12"],"badge":"green","countMode":"normal","id":"cleaner_001","name":"赵梅","role":"cleaner","verificationPaths":["cam01","protocol"]},{"allowedFloors":["B2","1","13"],"badge":"white","countMode":"normal","id":"security_001","name":"周启","role":"security","verificationPaths":["cam01","protocol"]}],"protocols":[{"category":"floor","condition":{"field":"floor","operator":"equals","value":13},"decision":"lockdown","id":"floor_13_forbidden","protocolTags":["floor"],"text":"13 层不存在,任何前往 13 层的请求必须封锁。","verificationPaths":["protocol","cam07"]},{"category":"identity","condition":{"field":"identity","operator":"equals","value":"maintenance_without_yellow_badge"},"decision":"lockdown","id":"maintenance_badge_required","protocolTags":["identity","personnel"],"text":"维修人员不计入人数,但必须佩戴黄色胸牌。","verificationPaths":["cam01","thermal"]},{"category":"device","condition":{"field":"cameraDelayMs","operator":"lte","value":2000},"decision":"release","id":"cam07_delay_expected","protocolTags":["device"],"text":"CAM-07 固定延迟两秒,延迟本身不属于异常。","verificationPaths":["cam01","replay"]},{"category":"time","condition":{"field":"cleanerOddFloorAfterMidnight","operator":"truthy"},"decision":"lockdown","id":"cleaner_after_midnight","protocolTags":["time","personnel"],"text":"零点后清洁人员只能前往偶数楼层。","verificationPaths":["protocol","cam01"]},{"category":"personnel","condition":{"field":"emptyWeightDelayCount","operator":"gte","value":2},"decision":"lockdown","id":"empty_car_weight_tolerance","protocolTags":["personnel","device"],"text":"空轿厢允许一次载重计数延迟,但第二次必须封锁。","verificationPaths":["cam01","replay"]},{"category":"device","condition":{"field":"maintenanceDoorCycles","operator":"lte","value":1},"decision":"release","id":"door_cycle_maintenance","protocolTags":["device"],"text":"维护灯亮起时允许一次门状态循环。","verificationPaths":["cam01","cam03"]}]};
+
// --- src/gameConfig.js ---
+var __exports_src_gameConfig_js = {};
+{
/**
* gameConfig.js — MINIGAME 平衡参数配置(单一配置源)
*
@@ -135,11 +140,16 @@ const CONFIG = {
CONFIG;
+__exports_src_gameConfig_js["CONFIG"] = CONFIG;
+}
+var CONFIG = __exports_src_gameConfig_js["CONFIG"];
// --- src/skins/elevator/skin.json ---
-var __SKIN_DATA__ = {"meta":{"id":"elevator","name":"异常电梯控制台","subtitle":"MINIGAME · ANOMALY SYSTEM SIM"},"monitor":{"initial":"监控画面稳定:1 层轿厢为空。","actions":{"openDoor":"监控:{floor} 层电梯门已打开。门外走廊光线异常。","closeDoor":"监控:轿厢门闭合。画面存在轻微拖影。","moveUp":"监控:电梯上行至 {floor} 层。乘客未看向摄像头。","moveDown":"监控:电梯下行至 {floor} 层。楼层指示灯短暂闪烁。","emergencyStop":"监控:电梯急停。轿厢灯光闪烁 3 次。","restartSystem":"监控:系统重启后恢复画面。部分录像帧丢失。"}},"actionLabels":{"openDoor":"开门","closeDoor":"关门","moveUp":"上行","moveDown":"下行","emergencyStop":"急停","restartSystem":"系统重启","inspectLog":"查看日志","unlockHiddenLog":"解码加密记录"},"doorLabels":{"open":"开启","closed":"关闭"},"directionLabels":{"up":"上行","down":"下行","idle":"待机"},"statusLabels":{"panelTitle":"电梯状态","floor":"楼层","door":"门状态","direction":"方向","passengers":"乘客","power":"电源","stability":"稳定度","anomalyLevel":"异常等级","reviveCount":"广告复活","adHintsCount":"加密解码","hiddenLogsCount":"待解码"},"canvasLabels":{"countdown":"值守倒计时","monitorPanel":"监控画面","actionPanel":"操作面板","logPanel":"系统日志","failureTitle":"系统崩溃","failureEyebrow":"SYSTEM FAILURE","monitorSignalStable":"SYSTEM: STABLE","monitorSignalUnstable":"SYSTEM: UNSTABLE","monitorSignalCorrupted":"SYSTEM: CORRUPTED","monitorThreat":"THREAT: {level}","failureMetricStability":"稳定度","failureMetricAnomaly":"异常","failureMetricRemaining":"剩余"},"actionFailMessages":{"openDoor_moving":"电梯移动中,禁止开门。","moveUp_doorNotClosed":"门未关闭,禁止移动。","moveDown_doorNotClosed":"门未关闭,禁止移动。","unknownAction":"未知操作:{actionId}","gameOver":"系统已崩溃,必须复活或重新开始。","systemBusy":"当前动作尚未完成,请等待电梯状态稳定。"},"actionFeedback":{"openDoor":"电梯门已打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行。","moveDown":"电梯开始下行。","emergencyStop":"急停已执行。","emergencyStop_fail":"急停按钮失效。","restartSystem":"系统重启完成。","inspectLog":"已查看系统日志。","unlockHiddenLog_noLocked":"没有待解码的加密记录。","unlockHiddenLog_limit":"本局已解码 {count} 条记录,达到上限。"},"actionLogMessages":{"openDoor":"电梯门已在 {floor} 层打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行,当前楼层 {floor}。","moveDown":"电梯开始下行,当前楼层 {floor}。","emergencyStop":"执行急停:移动已停止,稳定度下降。","emergencyStop_fail":"急停按钮无响应。异常等级上升。","restartSystem":"系统重启完成:异常等级下降,但消耗 {cost} 点电源。","inspectLog":"操作员查看系统日志:最近 30 秒存在未授权楼层请求。","inspectLog_hiddenRecords":"发现 {count} 条待解码加密记录。可观看模拟广告解锁完整内容。","unlockHiddenLog_ok":"模拟广告播放完成。加密记录已解码。"},"anomalies":[{"id":"phantom_floor","title":"不存在的楼层","severity":2,"monitor":"监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。","adHint":"楼层显示异常时不要开门,先执行系统重启。","effects":{"floor":"+2","anomalyLevel":2,"stability":-10}},{"id":"camera_delay","title":"监控延迟","severity":1,"monitor":"监控:画面延迟 3 秒。乘客动作与控制台记录不同步。","adHint":"监控延迟时优先查看日志,不要连续移动。","effects":{"anomalyLevel":1,"stability":-6}},{"id":"zero_passenger_shadow","title":"门外有人但乘客数为 0","severity":2,"monitor":"监控:门外站着一个人,但乘客计数器显示 0。","adHint":"乘客数异常时保持关门,先急停再查日志。","effects":{"passengers":0,"anomalyLevel":2,"stability":-12}},{"id":"log_echo","title":"系统日志重复字符","severity":1,"monitor":"监控:系统日志开始重复输出“不要开门”。","adHint":"日志重复通常是轻度异常,系统重启可降低异常等级。","effects":{"anomalyLevel":1,"stability":-5}},{"id":"auto_button","title":"按钮自动亮起","severity":2,"monitor":"监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。","adHint":"按钮自动亮起时不要跟随请求移动,先关门并急停。","effects":{"anomalyLevel":2,"power":-8}},{"id":"stop_failure","title":"急停按钮失效","severity":3,"monitor":"监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。","adHint":"急停失效时不要反复点击,优先系统重启。","effects":{"anomalyLevel":3,"stability":-15}},{"id":"negative_floor","title":"楼层显示为负数","severity":2,"monitor":"监控:楼层显示 -1。摄像头画面出现地下走廊。","adHint":"负数楼层不是正常地下层,立即重启系统。","effects":{"floor":-1,"anomalyLevel":2,"stability":-10}},{"id":"power_drain","title":"电源异常下降","severity":2,"monitor":"监控:备用电源自动接管,但电量仍在下降。","adHint":"电源异常下降时减少移动,优先关门与重启。","effects":{"anomalyLevel":2,"power":-22}},{"id":"door_refuse","title":"电梯门拒绝关闭","severity":2,"monitor":"监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。","adHint":"门拒绝关闭时不要连续按关门,先急停再重启系统。","effects":{"door":"open","anomalyLevel":2,"stability":-10}},{"id":"weight_mismatch","title":"载重数据异常","severity":1,"monitor":"监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。","adHint":"载重异常时优先查日志,乘客数可能被重置。","effects":{"passengers":0,"anomalyLevel":1,"stability":-7}},{"id":"floor_jump","title":"楼层编号跳跃","severity":2,"monitor":"监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。","adHint":"楼层跳跃时减少移动操作,用系统重启恢复楼层显示。","effects":{"floor":"+4","anomalyLevel":2,"stability":-12,"power":-10}},{"id":"emergency_lights","title":"应急灯异常启动","severity":3,"monitor":"监控:轿厢应急灯突然亮起。备用电源消耗加速。","adHint":"应急灯启动时尽量避免移动,立即重启系统可关闭应急灯。","effects":{"anomalyLevel":3,"stability":-14,"power":-20}}],"hiddenLogs":{"phantom_floor":{"title":"未归档楼层施工记录","content":"施工记录(编号模糊):存在未归档的夹层结构,位于正常楼层之间。\\n档案中未找到该夹层的施工许可或验收记录。\\n控制面板能收到来自该夹层的按钮信号,尽管物理按钮不存在于任何楼层。\\n技术人员备注:该信号可能与 3 年前失踪的 3 名工人有关。"},"camera_delay":{"title":"监控系统校准记录","content":"校准日志 #4417:摄像头#03 与#07 存在 3 秒信号延迟。\n技术人员备注:延迟与第 13 层信号干扰有关,建议不要在 13 层停靠。"},"zero_passenger_shadow":{"title":"乘客记录异常说明","content":"传感器技术手册(节选):\n红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。\n维修记录:传感器无故障。热源信号经比对——与员工体温档案不匹配。"},"log_echo":{"title":"日志系统诊断报告","content":"诊断报告 #FD-22-019:\n系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间。\n建议:检查前一值班员的退出状态。"},"auto_button":{"title":"控制系统审计追踪","content":"审计追踪 #AUD-882:\n自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。\n该服务器的最后一条记录:「控制权移交程序未完成」。"},"stop_failure":{"title":"急停系统维护日志","content":"维护日志 #M-341:\n急停回路#2 在定期检查中被标记为「状态:不可用」。\n签署人签名无法识别。签署时间:3 年前。没有后续维修记录。"},"negative_floor":{"title":"地下层勘测报告","content":"建筑勘测报告(内部):\n地下实际存在 4 层结构,但公开图纸仅标注 B1-B2。\nB3-B4 的电梯按钮在出厂时已被移除,但线路仍然通电。"},"power_drain":{"title":"备用电源异常报告","content":"异常报告 #P-877:\n备用电源在无负载状态下持续放电。经查,有一条非授权线路从备用电源柜分接至未知设备。\n线路标签:「不要切断」。"},"door_refuse":{"title":"门控系统事故报告","content":"事故报告 #D-1290:\n门控模块在连续 3 次异常重启后进入保护模式。\n模块日志输出最后一条:「识别到外部干扰信号。拒绝执行 — 保护乘员安全」。"},"weight_mismatch":{"title":"传感器校验记录","content":"校验记录 #W-554:\n载重传感器与红外传感器读数不一致。红外传感器在轿厢空载时检测到热源。\n技术人员备注:请确认值班员在操作前已清空轿厢。"},"floor_jump":{"title":"楼层定位日志","content":"定位日志 #F-213:\nGPS 楼层定位模块在校准前后记录的楼层编号不一致。\n系统自动修正失败。可能原因:参考信号源来自非标设备。"},"emergency_lights":{"title":"应急照明测试报告","content":"测试报告 #E-777:\n应急照明系统在无触发信号的情况下自行启动。\n供电线路检测到寄生回路。回路终端设备编号无法匹配任何已知设备清单。"}},"failure":{"summaries":{"power":"电源耗尽","stability":"稳定度归零","anomalyLevel":"异常等级失控","passengers":"乘客记录出现负数","default":"系统拒绝继续响应"},"defaultHint":"先关门,再重启系统,避免连续移动。","firstRunAdvice":"下次先核对画面、楼层、人数和门状态;一致放行,矛盾封锁。","adHintPrefix":"广告提示:{hint}","adReviveRollback":"广告复活完成:回滚 {seconds} 秒,恢复至可控状态。","adReviveMonitor":"广告复活完成:回滚到 {seconds} 秒前的系统状态。","snapshotFallback":"可观看广告复活,回滚到 {seconds} 秒前的系统状态。","noSnapshotFallback":"可观看广告复活,回滚到初始系统状态。"},"fakeEnding":{"eyebrow":"⚠ SYSTEM ANOMALY DETECTED","title":"操作员关联异常","text":"系统检测到操作员第 {count} 次系统崩溃。\n根据《异常控制员守则》第 7 条,您已被标记为“异常关联人员”。\n前 {threshold} 次记录已被永久删除。\n建议您立即离开控制台并联系安保部门。","truthPlaceholder":"[???] 观看广告揭示真相。","truthContent":"这不是第一次,也不会是最后一次。\n这座建筑的异常系统从未被修复。\n每一任值班员最后都变成了「异常事件」本身。\n系统日志中关于「乘客」的记载——都是前任值班员的热源信号。\n你现在坐的位置,就是上一任值班员被发现的地方。"},"ui":{"viewAd":"观看广告复活","unlockAd":"解码加密记录","restart":"重新开始","revealTruth":"观看广告揭示真相","triggerTest":"触发异常测试","decodePrefix":"[解码记录]","initialLog":"异常电梯控制台已接管。等待操作员指令。","initialFeedback":"等待下一班电梯","tutorialNormal":"信息一致,点击放行","tutorialAnomaly":"发现矛盾,点击封锁","coreRule":"核对画面和数据:一致放行,矛盾封锁","standby":"等待下一班","wrongTutorial":"再看一眼:核对楼层、人数和门状态","wrongTreatment":"处置错误,异常仍在持续。","inspectionReady":"请核对当前画面和三项数据","treatmentTutorial":"最后一步:按亮起的处置键解除异常","wrongTreatmentTutorial":"这项处置不对应当前线索,再看一次","autoResolutionCorrect":"封锁成功,系统已自动处置","autoResolutionWrong":"判断错误,系统已紧急隔离","autoResolutionTimeout":"判断超时,系统已自动隔离","anomalyEventLog":"异常事件:{title}。{hint}","startTitle":"等待接管异常电梯","startCopy":"核对楼层、人数和门状态:对得上就放行,对不上就封锁。前两班会在实际画面中教会你。","startChecklist":"三项一致:放行\n任意一项矛盾:封锁\n前两班点错不会扣分","startFailureRulesTitle":"失败条件","startFailureRules":"电源归零\n稳定度归零\n异常等级失控","startButton":"开始接管","sidebarEntry":"侧边栏入口","pausedTitle":"值守已暂停","pausedCopy":"返回前台后继续,不计算后台时间","audioOn":"声音开","audioOff":"已静音","adUnavailable":"广告暂不可用,请稍后重试","reportNormal":"放行","reportAnomaly":"封锁","inspectionLabel":"请在 {seconds}s 内判断","baselineInspectionTitle":"核对画面与数据","anomalyInspectionTitle":"核对画面与数据","anomalyResolved":"处置完成:{action} 已解除当前异常。","anomalyResolvedMonitor":"监控恢复稳定,等待下一轮巡检。","inspectionPrompt":"巡检判定:{title}({seconds}秒内响应)","inspectionCorrectNormal":"判定正确:当前画面正常。","inspectionCorrectAnomaly":"判定正确:异常已上报,系统压力下降。","inspectionWrong":"判定错误:稳定度下降,异常压力上升。","inspectionTimeout":"判定超时:未完成本次巡检。","successfulShift":"本轮结束,连续失败计数已重置。","shiftComplete":"值守完成","hiddenLogCaptured":"加密记录已捕获:{title}。使用「查看日志」功能解码。","unlockResult":"已解码:{title}","decodeMonitor":"解码完成:{title}。完整内容已写入系统日志。"}};
+var __SKIN_DATA__ = {"meta":{"id":"elevator","name":"异常电梯控制台","subtitle":"MINIGAME · ANOMALY SYSTEM SIM"},"monitor":{"initial":"监控画面稳定:1 层轿厢为空。","actions":{"openDoor":"监控:{floor} 层电梯门已打开。门外走廊光线异常。","closeDoor":"监控:轿厢门闭合。画面存在轻微拖影。","moveUp":"监控:电梯上行至 {floor} 层。乘客未看向摄像头。","moveDown":"监控:电梯下行至 {floor} 层。楼层指示灯短暂闪烁。","emergencyStop":"监控:电梯急停。轿厢灯光闪烁 3 次。","restartSystem":"监控:系统重启后恢复画面。部分录像帧丢失。"}},"actionLabels":{"openDoor":"开门","closeDoor":"关门","moveUp":"上行","moveDown":"下行","emergencyStop":"急停","restartSystem":"系统重启","inspectLog":"查看日志","unlockHiddenLog":"解码加密记录"},"doorLabels":{"open":"开启","closed":"关闭"},"directionLabels":{"up":"上行","down":"下行","idle":"待机"},"statusLabels":{"panelTitle":"电梯状态","floor":"楼层","door":"门状态","direction":"方向","passengers":"乘客","power":"电源","stability":"稳定度","anomalyLevel":"异常等级","reviveCount":"广告复活","adHintsCount":"加密解码","hiddenLogsCount":"待解码"},"canvasLabels":{"countdown":"值守倒计时","monitorPanel":"监控画面","actionPanel":"操作面板","logPanel":"系统日志","failureTitle":"系统崩溃","failureEyebrow":"系统故障","monitorSignalStable":"SYSTEM: STABLE","monitorSignalUnstable":"SYSTEM: UNSTABLE","monitorSignalCorrupted":"SYSTEM: CORRUPTED","monitorThreat":"THREAT: {level}","failureMetricStability":"稳定度","failureMetricAnomaly":"异常","failureMetricRemaining":"剩余"},"actionFailMessages":{"openDoor_moving":"电梯移动中,禁止开门。","moveUp_doorNotClosed":"门未关闭,禁止移动。","moveDown_doorNotClosed":"门未关闭,禁止移动。","unknownAction":"未知操作:{actionId}","gameOver":"系统已崩溃,必须复活或重新开始。","systemBusy":"当前动作尚未完成,请等待电梯状态稳定。"},"actionFeedback":{"openDoor":"电梯门已打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行。","moveDown":"电梯开始下行。","emergencyStop":"急停已执行。","emergencyStop_fail":"急停按钮失效。","restartSystem":"系统重启完成。","inspectLog":"已查看系统日志。","unlockHiddenLog_noLocked":"没有待解码的加密记录。","unlockHiddenLog_limit":"本局已解码 {count} 条记录,达到上限。"},"actionLogMessages":{"openDoor":"电梯门已在 {floor} 层打开。","closeDoor":"电梯门已关闭。","moveUp":"电梯开始上行,当前楼层 {floor}。","moveDown":"电梯开始下行,当前楼层 {floor}。","emergencyStop":"执行急停:移动已停止,稳定度下降。","emergencyStop_fail":"急停按钮无响应。异常等级上升。","restartSystem":"系统重启完成:异常等级下降,但消耗 {cost} 点电源。","inspectLog":"操作员查看系统日志:最近 30 秒存在未授权楼层请求。","inspectLog_hiddenRecords":"发现 {count} 条待解码加密记录。可观看模拟广告解锁完整内容。","unlockHiddenLog_ok":"模拟广告播放完成。加密记录已解码。"},"anomalies":[{"id":"phantom_floor","title":"不存在的楼层","severity":2,"monitor":"监控:楼层读数跳到 {normalFloorPlus2} 层。该楼层在建筑图纸中不存在。","adHint":"楼层显示异常时不要开门,先执行系统重启。","effects":{"floor":"+2","anomalyLevel":2,"stability":-10}},{"id":"camera_delay","title":"监控延迟","severity":1,"monitor":"监控:画面延迟 3 秒。乘客动作与控制台记录不同步。","adHint":"监控延迟时优先查看日志,不要连续移动。","effects":{"anomalyLevel":1,"stability":-6}},{"id":"zero_passenger_shadow","title":"门外有人但乘客数为 0","severity":2,"monitor":"监控:门外站着一个人,但乘客计数器显示 0。","adHint":"乘客数异常时保持关门,先急停再查日志。","effects":{"passengers":0,"anomalyLevel":2,"stability":-12}},{"id":"log_echo","title":"系统日志重复字符","severity":1,"monitor":"监控:系统日志开始重复输出“不要开门”。","adHint":"日志重复通常是轻度异常,系统重启可降低异常等级。","effects":{"anomalyLevel":1,"stability":-5}},{"id":"auto_button","title":"按钮自动亮起","severity":2,"monitor":"监控:没有乘客触碰按钮,B2 与 9 层按钮自动亮起。","adHint":"按钮自动亮起时不要跟随请求移动,先关门并急停。","effects":{"anomalyLevel":2,"power":-8}},{"id":"stop_failure","title":"急停按钮失效","severity":3,"monitor":"监控:急停按钮指示灯熄灭,控制台拒绝确认安全回路。","adHint":"急停失效时不要反复点击,优先系统重启。","effects":{"anomalyLevel":3,"stability":-15}},{"id":"negative_floor","title":"楼层显示为负数","severity":2,"monitor":"监控:楼层显示 -1。摄像头画面出现地下走廊。","adHint":"负数楼层不是正常地下层,立即重启系统。","effects":{"floor":-1,"anomalyLevel":2,"stability":-10}},{"id":"power_drain","title":"电源异常下降","severity":2,"monitor":"监控:备用电源自动接管,但电量仍在下降。","adHint":"电源异常下降时减少移动,优先关门与重启。","effects":{"anomalyLevel":2,"power":-22}},{"id":"door_refuse","title":"电梯门拒绝关闭","severity":2,"monitor":"监控:关门按钮已按下,门在合拢前自动弹开。异常状态持续。","adHint":"门拒绝关闭时不要连续按关门,先急停再重启系统。","effects":{"door":"open","anomalyLevel":2,"stability":-10}},{"id":"weight_mismatch","title":"载重数据异常","severity":1,"monitor":"监控:载重传感器读数 — 0kg。轿厢内有 1 名乘客。读数矛盾。","adHint":"载重异常时优先查日志,乘客数可能被重置。","effects":{"passengers":0,"anomalyLevel":1,"stability":-7}},{"id":"floor_jump","title":"楼层编号跳跃","severity":2,"monitor":"监控:电梯从 5 层直接移动到 9 层。摄像头画面缺失 4 帧。","adHint":"楼层跳跃时减少移动操作,用系统重启恢复楼层显示。","effects":{"floor":"+4","anomalyLevel":2,"stability":-12,"power":-10}},{"id":"emergency_lights","title":"应急灯异常启动","severity":3,"monitor":"监控:轿厢应急灯突然亮起。备用电源消耗加速。","adHint":"应急灯启动时尽量避免移动,立即重启系统可关闭应急灯。","effects":{"anomalyLevel":3,"stability":-14,"power":-20}}],"hiddenLogs":{"phantom_floor":{"title":"未归档楼层施工记录","content":"施工记录(编号模糊):存在未归档的夹层结构,位于正常楼层之间。\\n档案中未找到该夹层的施工许可或验收记录。\\n控制面板能收到来自该夹层的按钮信号,尽管物理按钮不存在于任何楼层。\\n技术人员备注:该信号可能与 3 年前失踪的 3 名工人有关。"},"camera_delay":{"title":"监控系统校准记录","content":"校准日志 #4417:摄像头#03 与#07 存在 3 秒信号延迟。\n技术人员备注:延迟与第 13 层信号干扰有关,建议不要在 13 层停靠。"},"zero_passenger_shadow":{"title":"乘客记录异常说明","content":"传感器技术手册(节选):\n红外传感器在非营业时段多次检测到热源信号,但乘客计数器持续归零。\n维修记录:传感器无故障。热源信号经比对——与员工体温档案不匹配。"},"log_echo":{"title":"日志系统诊断报告","content":"诊断报告 #FD-22-019:\n系统日志缓冲区检测到重复写入操作。重复内容「不要开门」的写入时间戳早于当前值班员登录时间。\n建议:检查前一值班员的退出状态。"},"auto_button":{"title":"控制系统审计追踪","content":"审计追踪 #AUD-882:\n自动按钮信号来源追溯至 5 号服务器(已于 2022 年停用)。\n该服务器的最后一条记录:「控制权移交程序未完成」。"},"stop_failure":{"title":"急停系统维护日志","content":"维护日志 #M-341:\n急停回路#2 在定期检查中被标记为「状态:不可用」。\n签署人签名无法识别。签署时间:3 年前。没有后续维修记录。"},"negative_floor":{"title":"地下层勘测报告","content":"建筑勘测报告(内部):\n地下实际存在 4 层结构,但公开图纸仅标注 B1-B2。\nB3-B4 的电梯按钮在出厂时已被移除,但线路仍然通电。"},"power_drain":{"title":"备用电源异常报告","content":"异常报告 #P-877:\n备用电源在无负载状态下持续放电。经查,有一条非授权线路从备用电源柜分接至未知设备。\n线路标签:「不要切断」。"},"door_refuse":{"title":"门控系统事故报告","content":"事故报告 #D-1290:\n门控模块在连续 3 次异常重启后进入保护模式。\n模块日志输出最后一条:「识别到外部干扰信号。拒绝执行 — 保护乘员安全」。"},"weight_mismatch":{"title":"传感器校验记录","content":"校验记录 #W-554:\n载重传感器与红外传感器读数不一致。红外传感器在轿厢空载时检测到热源。\n技术人员备注:请确认值班员在操作前已清空轿厢。"},"floor_jump":{"title":"楼层定位日志","content":"定位日志 #F-213:\nGPS 楼层定位模块在校准前后记录的楼层编号不一致。\n系统自动修正失败。可能原因:参考信号源来自非标设备。"},"emergency_lights":{"title":"应急照明测试报告","content":"测试报告 #E-777:\n应急照明系统在无触发信号的情况下自行启动。\n供电线路检测到寄生回路。回路终端设备编号无法匹配任何已知设备清单。"}},"failure":{"summaries":{"power":"电源耗尽","stability":"稳定度归零","anomalyLevel":"异常等级失控","passengers":"乘客记录出现负数","default":"系统拒绝继续响应"},"defaultHint":"先关门,再重启系统,避免连续移动。","firstRunAdvice":"下次先核对画面、楼层、人数和门状态;一致放行,矛盾封锁。","adHintPrefix":"广告提示:{hint}","adReviveRollback":"广告复活完成:回滚 {seconds} 秒,恢复至可控状态。","adReviveMonitor":"广告复活完成:回滚到 {seconds} 秒前的系统状态。","snapshotFallback":"可观看广告复活,回滚到 {seconds} 秒前的系统状态。","noSnapshotFallback":"可观看广告复活,回滚到初始系统状态。"},"fakeEnding":{"eyebrow":"⚠ SYSTEM ANOMALY DETECTED","title":"操作员关联异常","text":"系统检测到操作员第 {count} 次系统崩溃。\n根据《异常控制员守则》第 7 条,您已被标记为“异常关联人员”。\n前 {threshold} 次记录已被永久删除。\n建议您立即离开控制台并联系安保部门。","truthPlaceholder":"[???] 观看广告揭示真相。","truthContent":"这不是第一次,也不会是最后一次。\n这座建筑的异常系统从未被修复。\n每一任值班员最后都变成了「异常事件」本身。\n系统日志中关于「乘客」的记载——都是前任值班员的热源信号。\n你现在坐的位置,就是上一任值班员被发现的地方。"},"ui":{"viewAd":"观看广告复活","unlockAd":"解码加密记录","restart":"重新开始","revealTruth":"观看广告揭示真相","triggerTest":"触发异常测试","decodePrefix":"[解码记录]","initialLog":"异常电梯控制台已接管。等待操作员指令。","initialFeedback":"等待下一班电梯","tutorialNormal":"信息一致,点击放行","tutorialAnomaly":"发现矛盾,点击封锁","coreRule":"核对画面和数据:一致放行,矛盾封锁","standby":"等待下一班","wrongTutorial":"再看一眼:核对楼层、人数和门状态","wrongTreatment":"处置错误,异常仍在持续。","inspectionReady":"请核对当前画面和三项数据","treatmentTutorial":"最后一步:按亮起的处置键解除异常","wrongTreatmentTutorial":"这项处置不对应当前线索,再看一次","autoResolutionCorrect":"封锁成功,系统已自动处置","autoResolutionWrong":"判断错误,系统已紧急隔离","autoResolutionTimeout":"判断超时,系统已自动隔离","anomalyEventLog":"异常事件:{title}。{hint}","startTitle":"等待接管异常电梯","startCopy":"核对楼层、人数和门状态:对得上就放行,对不上就封锁。前两班会在实际画面中教会你。","startChecklist":"三项一致:放行\n任意一项矛盾:封锁\n前两班点错不会扣分","startFailureRulesTitle":"失败条件","startFailureRules":"电源归零\n稳定度归零\n异常等级失控","startButton":"开始接管","sidebarEntry":"侧边栏入口","pausedTitle":"值守已暂停","pausedCopy":"返回前台后继续,不计算后台时间","audioOn":"声音开","audioOff":"已静音","adUnavailable":"广告暂不可用,请稍后重试","reportNormal":"放行","reportAnomaly":"封锁","inspectionLabel":"请在 {seconds}s 内判断","baselineInspectionTitle":"核对画面与数据","anomalyInspectionTitle":"核对画面与数据","anomalyResolved":"处置完成:{action} 已解除当前异常。","anomalyResolvedMonitor":"监控恢复稳定,等待下一轮巡检。","inspectionPrompt":"巡检判定:{title}({seconds}秒内响应)","inspectionCorrectNormal":"判定正确:当前画面正常。","inspectionCorrectAnomaly":"判定正确:异常已上报,系统压力下降。","inspectionWrong":"判定错误:稳定度下降,异常压力上升。","inspectionTimeout":"判定超时:未完成本次巡检。","successfulShift":"本轮结束,连续失败计数已重置。","shiftComplete":"值守完成","hiddenLogCaptured":"加密记录已捕获:{title}。使用「查看日志」功能解码。","unlockResult":"已解码:{title}","decodeMonitor":"解码完成:{title}。完整内容已写入系统日志。"}};
// --- src/skinManager.js ---
+var __exports_src_skinManager_js = {};
+{
/**
* skinManager.js — 换皮系统核心
*
@@ -247,8 +257,29 @@ function actionLabel(actionId, count) {
return label;
}
+__exports_src_skinManager_js["loadSkin"] = loadSkin;
+__exports_src_skinManager_js["getSkin"] = getSkin;
+__exports_src_skinManager_js["t"] = t;
+__exports_src_skinManager_js["getAnomalies"] = getAnomalies;
+__exports_src_skinManager_js["getAnomaly"] = getAnomaly;
+__exports_src_skinManager_js["getHiddenLog"] = getHiddenLog;
+__exports_src_skinManager_js["applyEffects"] = applyEffects;
+__exports_src_skinManager_js["actionText"] = actionText;
+__exports_src_skinManager_js["actionLabel"] = actionLabel;
+}
+var loadSkin = __exports_src_skinManager_js["loadSkin"];
+var getSkin = __exports_src_skinManager_js["getSkin"];
+var t = __exports_src_skinManager_js["t"];
+var getAnomalies = __exports_src_skinManager_js["getAnomalies"];
+var getAnomaly = __exports_src_skinManager_js["getAnomaly"];
+var getHiddenLog = __exports_src_skinManager_js["getHiddenLog"];
+var applyEffects = __exports_src_skinManager_js["applyEffects"];
+var actionText = __exports_src_skinManager_js["actionText"];
+var actionLabel = __exports_src_skinManager_js["actionLabel"];
// --- src/rollback.js ---
+var __exports_src_rollback_js = {};
+{
function findRollbackSnapshot(snapshots, elapsed) {
if (!snapshots || snapshots.length === 0) return null;
@@ -265,8 +296,13 @@ function findRollbackSnapshot(snapshots, elapsed) {
return best;
}
+__exports_src_rollback_js["findRollbackSnapshot"] = findRollbackSnapshot;
+}
+var findRollbackSnapshot = __exports_src_rollback_js["findRollbackSnapshot"];
// --- src/feedback.js ---
+var __exports_src_feedback_js = {};
+{
function classifyFeedbackPriority(type) {
@@ -323,8 +359,676 @@ function getToneForState(state) {
return 'normal';
}
+__exports_src_feedback_js["classifyFeedbackPriority"] = classifyFeedbackPriority;
+__exports_src_feedback_js["createFeedbackLine"] = createFeedbackLine;
+__exports_src_feedback_js["summarizeFailure"] = summarizeFailure;
+__exports_src_feedback_js["getToneForState"] = getToneForState;
+}
+var classifyFeedbackPriority = __exports_src_feedback_js["classifyFeedbackPriority"];
+var createFeedbackLine = __exports_src_feedback_js["createFeedbackLine"];
+var summarizeFailure = __exports_src_feedback_js["summarizeFailure"];
+var getToneForState = __exports_src_feedback_js["getToneForState"];
+
+// --- src/protocolEngine.js ---
+var __exports_src_protocolEngine_js = {};
+{
+function compare(value, operator, expected) {
+ if (operator === 'equals') return value === expected;
+ if (operator === 'lte') return Number(value) <= Number(expected);
+ if (operator === 'gte') return Number(value) >= Number(expected);
+ if (operator === 'truthy') return Boolean(value);
+ return false;
+}
+
+function protocolAppliesToShift(protocol, shift = {}) {
+ const tags = new Set(shift.protocolTags || []);
+ return (protocol.protocolTags || []).some(tag => tags.has(tag));
+}
+
+function evaluateProtocolDecision(protocol, shift = {}) {
+ const condition = protocol?.condition || {};
+ const observed = shift.screenData?.[condition.field]
+ ?? shift.panelData?.[condition.field]
+ ?? shift.evidence?.[condition.field];
+ const matched = compare(observed, condition.operator, condition.value);
+ const violated = protocol?.decision === 'lockdown' ? matched : !matched;
+ return {
+ violated,
+ decision: violated ? 'lockdown' : 'release',
+ observed,
+ expected: condition.value,
+ verificationPaths: [...(protocol?.verificationPaths || [])],
+ };
+}
+
+function evaluateNightProtocolSet(protocols = [], shift = {}) {
+ const applied = protocols.filter(protocol => protocolAppliesToShift(protocol, shift));
+ const results = applied.map(protocol => ({ protocol, result: evaluateProtocolDecision(protocol, shift) }));
+ const violated = results.filter(item => item.result.violated);
+ return {
+ decision: violated.length ? 'lockdown' : 'release',
+ appliedProtocolIds: applied.map(protocol => protocol.id),
+ violatedProtocolIds: violated.map(item => item.protocol.id),
+ verificationPaths: [...new Set(results.flatMap(item => item.result.verificationPaths))].sort(),
+ };
+}
+
+function generateNightProtocols({ protocols = [], shifts = [], count = 2, random = Math.random } = {}) {
+ const target = Math.max(2, Math.min(3, Math.trunc(count || 2)));
+ const applicable = protocols.filter(protocol => shifts.some(shift => protocolAppliesToShift(protocol, shift)));
+ const selected = [];
+ if (applicable.length) selected.push(applicable[Math.floor(random() * applicable.length) % applicable.length]);
+ const remaining = protocols.filter(protocol => !selected.some(item => item.id === protocol.id));
+ while (selected.length < target && remaining.length) {
+ const index = Math.floor(random() * remaining.length) % remaining.length;
+ selected.push(remaining.splice(index, 1)[0]);
+ }
+ return selected;
+}
+
+__exports_src_protocolEngine_js["protocolAppliesToShift"] = protocolAppliesToShift;
+__exports_src_protocolEngine_js["evaluateProtocolDecision"] = evaluateProtocolDecision;
+__exports_src_protocolEngine_js["evaluateNightProtocolSet"] = evaluateNightProtocolSet;
+__exports_src_protocolEngine_js["generateNightProtocols"] = generateNightProtocols;
+}
+var protocolAppliesToShift = __exports_src_protocolEngine_js["protocolAppliesToShift"];
+var evaluateProtocolDecision = __exports_src_protocolEngine_js["evaluateProtocolDecision"];
+var evaluateNightProtocolSet = __exports_src_protocolEngine_js["evaluateNightProtocolSet"];
+var generateNightProtocols = __exports_src_protocolEngine_js["generateNightProtocols"];
+
+// --- src/evidenceEngine.js ---
+var __exports_src_evidenceEngine_js = {};
+{
+const CORE_FIELDS = Object.freeze(['floor', 'passengers', 'door']);
+const FIELD_LABELS = Object.freeze({ floor: '楼层', passengers: '人数', door: '门状态' });
+
+function compareCoreEvidence(screenData = {}, panelData = {}) {
+ return CORE_FIELDS
+ .filter(field => screenData[field] !== panelData[field])
+ .map(field => ({ field, screen: screenData[field], panel: panelData[field] }));
+}
+
+function evaluateEvidence({ screenData = {}, panelData = {}, protocolResult = null } = {}) {
+ const conflicts = compareCoreEvidence(screenData, panelData);
+ if (protocolResult?.violated) {
+ conflicts.push({ field: 'protocol', screen: protocolResult.observed, panel: protocolResult.expected });
+ }
+ const decision = conflicts.length ? 'lockdown' : 'release';
+ const explanation = conflicts.length
+ ? conflicts.map(item => `${FIELD_LABELS[item.field] || '协议'}不一致`).join(';')
+ : '画面与主控数据一致。';
+ return {
+ decision,
+ conflicts,
+ explanation,
+ presentationTone: 'neutral',
+ highlightConflictBeforeDecision: false,
+ };
+}
+
+function evaluateInvestigationEvidence(discoveredEvidence = []) {
+ const contradictions = discoveredEvidence.filter(item => item?.contradicts && item?.conflictKey && item?.source);
+ const groups = new Map();
+ for (const evidence of contradictions) {
+ const sources = groups.get(evidence.conflictKey) || new Set();
+ sources.add(evidence.source);
+ groups.set(evidence.conflictKey, sources);
+ }
+ const corroborated = [...groups.entries()].filter(([, sources]) => sources.size >= 2);
+ const verificationPaths = [...new Set(
+ corroborated.flatMap(([, sources]) => [...sources]),
+ )].sort();
+ const ready = corroborated.length > 0;
+ return {
+ ready,
+ decision: ready ? 'lockdown' : null,
+ conflicts: corroborated.map(([conflictKey]) => conflictKey),
+ verificationPaths,
+ presentationTone: 'neutral',
+ };
+}
+
+function isEvidenceJudgeableWithoutAudio(shift = {}) {
+ const conflicts = compareCoreEvidence(shift.screenData, shift.panelData);
+ const cameras = shift.evidence?.cameras || [];
+ const tools = shift.evidence?.tools || [];
+ return conflicts.length > 0 || cameras.length > 0 || tools.some(tool => tool !== 'audio');
+}
+
+__exports_src_evidenceEngine_js["compareCoreEvidence"] = compareCoreEvidence;
+__exports_src_evidenceEngine_js["evaluateEvidence"] = evaluateEvidence;
+__exports_src_evidenceEngine_js["evaluateInvestigationEvidence"] = evaluateInvestigationEvidence;
+__exports_src_evidenceEngine_js["isEvidenceJudgeableWithoutAudio"] = isEvidenceJudgeableWithoutAudio;
+}
+var compareCoreEvidence = __exports_src_evidenceEngine_js["compareCoreEvidence"];
+var evaluateEvidence = __exports_src_evidenceEngine_js["evaluateEvidence"];
+var evaluateInvestigationEvidence = __exports_src_evidenceEngine_js["evaluateInvestigationEvidence"];
+var isEvidenceJudgeableWithoutAudio = __exports_src_evidenceEngine_js["isEvidenceJudgeableWithoutAudio"];
+
+// --- src/investigationTools.js ---
+var __exports_src_investigationTools_js = {};
+{
+const TOOL_CONFIG = Object.freeze({
+ thermal: Object.freeze({ uses: 2, powerCost: 8, evidenceKey: 'thermal' }),
+ replay: Object.freeze({ uses: 2, powerCost: 4, evidenceKey: 'replay' }),
+ protocol: Object.freeze({ uses: Number.POSITIVE_INFINITY, powerCost: 0, evidenceKey: 'protocol' }),
+});
+
+function cloneInvestigationState(state) {
+ return {
+ ...state,
+ tools: Object.fromEntries(
+ Object.entries(state.tools || {}).map(([id, tool]) => [id, { ...tool }]),
+ ),
+ discoveredEvidence: [...(state.discoveredEvidence || [])],
+ };
+}
+
+function createInvestigationState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ activeCamera: 'cam01',
+ tools: Object.fromEntries(
+ Object.entries(TOOL_CONFIG).map(([id, config]) => [id, {
+ remaining: config.uses,
+ powerCost: config.powerCost,
+ }]),
+ ),
+ discoveredEvidence: [],
+ };
+}
+
+function switchCamera(state, cameraId, shift = {}) {
+ if (!(shift.cameras || []).includes(cameraId)) {
+ return { state, accepted: false, reason: 'camera-unavailable', visibleEvidence: [] };
+ }
+ const next = cloneInvestigationState(state);
+ next.activeCamera = cameraId;
+ const visibleEvidence = [...(shift.evidence?.cameras?.[cameraId] || [])];
+ for (const evidence of visibleEvidence) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, visibleEvidence };
+}
+
+function useInvestigationTool(state, toolId, shift = {}) {
+ const config = TOOL_CONFIG[toolId];
+ const currentTool = state?.tools?.[toolId];
+ if (!config || !currentTool) return { state, accepted: false, reason: 'unknown-tool' };
+ if (currentTool.remaining <= 0) return { state, accepted: false, reason: 'no-uses' };
+ if ((state.power ?? 0) < config.powerCost) return { state, accepted: false, reason: 'insufficient-power' };
+
+ const next = cloneInvestigationState(state);
+ next.power = Math.max(0, next.power - config.powerCost);
+ if (Number.isFinite(next.tools[toolId].remaining)) next.tools[toolId].remaining -= 1;
+ const discoveredEvidence = toolId === 'protocol'
+ ? [...(shift.activeProtocols || [])]
+ : shift.evidence?.[config.evidenceKey] ?? null;
+ const evidenceItems = Array.isArray(discoveredEvidence)
+ ? discoveredEvidence
+ : discoveredEvidence ? [discoveredEvidence] : [];
+ for (const evidence of evidenceItems) {
+ if (!next.discoveredEvidence.some(item => item.id === evidence.id)) next.discoveredEvidence.push(evidence);
+ }
+ return { state: next, accepted: true, discoveredEvidence };
+}
+
+__exports_src_investigationTools_js["createInvestigationState"] = createInvestigationState;
+__exports_src_investigationTools_js["switchCamera"] = switchCamera;
+__exports_src_investigationTools_js["useInvestigationTool"] = useInvestigationTool;
+}
+var createInvestigationState = __exports_src_investigationTools_js["createInvestigationState"];
+var switchCamera = __exports_src_investigationTools_js["switchCamera"];
+var useInvestigationTool = __exports_src_investigationTools_js["useInvestigationTool"];
+
+// --- src/identitySystem.js ---
+var __exports_src_identitySystem_js = {};
+{
+function verifyPassengerIdentity(passenger = {}, observation = {}) {
+ const conflicts = [];
+ if (passenger.badge != null && observation.badge !== passenger.badge) conflicts.push('badge');
+ if (Array.isArray(passenger.allowedFloors)
+ && !passenger.allowedFloors.map(String).includes(String(observation.requestedFloor))) {
+ conflicts.push('floor');
+ }
+ return {
+ valid: conflicts.length === 0,
+ conflicts,
+ passengerId: passenger.id ?? null,
+ verificationPaths: ['cam01', 'protocol'],
+ };
+}
+
+function countPassengersForPanel(passengers = []) {
+ return passengers.filter(passenger => passenger.countMode !== 'ignore').length;
+}
+
+__exports_src_identitySystem_js["verifyPassengerIdentity"] = verifyPassengerIdentity;
+__exports_src_identitySystem_js["countPassengersForPanel"] = countPassengersForPanel;
+}
+var verifyPassengerIdentity = __exports_src_identitySystem_js["verifyPassengerIdentity"];
+var countPassengersForPanel = __exports_src_identitySystem_js["countPassengersForPanel"];
+
+// --- src/eventChainEngine.js ---
+var __exports_src_eventChainEngine_js = {};
+{
+function cloneChainState(state) {
+ return {
+ chains: Object.fromEntries(Object.entries(state.chains || {}).map(([id, value]) => [id, { ...value }])),
+ flags: [...(state.flags || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+function createEventChainState(chains = []) {
+ return {
+ chains: Object.fromEntries(chains.map(chain => [chain.id, { stepIndex: 0, completed: false }])),
+ flags: [...new Set(chains.flatMap(chain => chain.initialFlags || []))],
+ history: [],
+ };
+}
+
+function getCurrentEventStep(state, chain) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return null;
+ return chain.steps?.[progress.stepIndex] ?? null;
+}
+
+function advanceEventChain(state, chain, outcome = {}) {
+ const progress = state?.chains?.[chain.id];
+ if (!progress || progress.completed) return { state, accepted: false, completed: Boolean(progress?.completed), consequences: [] };
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return { state, accepted: false, completed: true, consequences: [] };
+
+ const next = cloneChainState(state);
+ if (outcome.correct === false) {
+ next.flags.push(...(step.onWrongFlags || []));
+ next.flags = [...new Set(next.flags)];
+ }
+ const nextIndex = progress.stepIndex + 1;
+ const completed = nextIndex >= chain.steps.length;
+ next.chains[chain.id] = { stepIndex: nextIndex, completed };
+ next.history.push({ chainId: chain.id, stepId: step.id, correct: outcome.correct !== false });
+ const consequences = completed
+ ? (chain.consequences || []).filter(item => !item.flag || next.flags.includes(item.flag))
+ : [];
+ return { state: next, accepted: true, completed, consequences };
+}
+
+__exports_src_eventChainEngine_js["createEventChainState"] = createEventChainState;
+__exports_src_eventChainEngine_js["getCurrentEventStep"] = getCurrentEventStep;
+__exports_src_eventChainEngine_js["advanceEventChain"] = advanceEventChain;
+}
+var createEventChainState = __exports_src_eventChainEngine_js["createEventChainState"];
+var getCurrentEventStep = __exports_src_eventChainEngine_js["getCurrentEventStep"];
+var advanceEventChain = __exports_src_eventChainEngine_js["advanceEventChain"];
+
+// --- src/highRiskResolution.js ---
+var __exports_src_highRiskResolution_js = {};
+{
+const HIGH_RISK_ACTIONS = Object.freeze(['emergencyStop', 'restart', 'lockdownFloor']);
+
+function cloneHighRiskState(state) {
+ return {
+ ...state,
+ resolvedEvents: [...(state.resolvedEvents || [])],
+ nextShiftModifiers: [...(state.nextShiftModifiers || [])],
+ history: [...(state.history || [])],
+ };
+}
+
+function createHighRiskState({ power = 100 } = {}) {
+ return {
+ power: Math.max(0, Number(power) || 0),
+ resolvedEvents: [],
+ nextShiftModifiers: [],
+ history: [],
+ gameOver: false,
+ };
+}
+
+function resolveHighRiskAction(state, event = {}, action) {
+ if (!HIGH_RISK_ACTIONS.includes(action)) return { state, accepted: false, correct: false, reason: 'unknown-action' };
+ const cost = Math.max(0, Number(event.costs?.[action] || 0));
+ if ((state.power ?? 0) < cost) return { state, accepted: false, correct: false, reason: 'insufficient-power' };
+
+ const next = cloneHighRiskState(state);
+ next.power -= cost;
+ const correct = (event.acceptedActions || []).includes(action);
+ if (correct && event.id && !next.resolvedEvents.includes(event.id)) next.resolvedEvents.push(event.id);
+ const modifier = correct ? event.successModifier : event.wrongModifiers?.[action];
+ if (modifier && !next.nextShiftModifiers.includes(modifier)) next.nextShiftModifiers.push(modifier);
+ next.history.push({ eventId: event.id ?? null, action, correct, powerCost: cost });
+ return { state: next, accepted: true, correct };
+}
+
+__exports_src_highRiskResolution_js["createHighRiskState"] = createHighRiskState;
+__exports_src_highRiskResolution_js["resolveHighRiskAction"] = resolveHighRiskAction;
+}
+var createHighRiskState = __exports_src_highRiskResolution_js["createHighRiskState"];
+var resolveHighRiskAction = __exports_src_highRiskResolution_js["resolveHighRiskAction"];
+
+// --- src/contamination.js ---
+var __exports_src_contamination_js = {};
+{
+function clamp(value, min = 0, max = 100) {
+ return Math.max(min, Math.min(max, Number(value) || 0));
+}
+
+function getContaminationTier(value) {
+ const normalized = clamp(value);
+ if (normalized >= 76) return 'severe';
+ if (normalized >= 51) return 'medium';
+ if (normalized >= 26) return 'light';
+ return 'normal';
+}
+
+function createContaminationState(value = 0) {
+ const normalized = clamp(value);
+ return { value: normalized, tier: getContaminationTier(normalized), history: [] };
+}
+
+function changeContamination(state, delta, reason) {
+ const current = state || createContaminationState();
+ const value = clamp(current.value + Number(delta || 0));
+ return {
+ value,
+ tier: getContaminationTier(value),
+ history: [...(current.history || []), { delta: Number(delta || 0), reason, value }],
+ };
+}
+
+function applyDecisionContamination(state, decision = {}) {
+ const effects = decision.contaminationEffects || {};
+ const delta = decision.correct === false
+ ? Number(effects.onMiss || 0)
+ : Number(effects.onCorrect || 0);
+ return changeContamination(state, delta, {
+ type: decision.correct === false ? 'wrong-decision' : 'correct-decision',
+ contentId: decision.contentId ?? null,
+ });
+}
+
+function deriveContaminationEffects(value) {
+ const tier = getContaminationTier(value);
+ const reliability = {
+ normal: {
+ reliable: ['panel', 'cam01', 'cam03', 'cam07', 'thermal', 'replay'],
+ unreliable: [],
+ },
+ light: {
+ reliable: ['panel', 'cam01', 'cam03', 'thermal', 'replay'],
+ unreliable: ['cam07'],
+ },
+ medium: {
+ reliable: ['cam01', 'thermal', 'replay'],
+ unreliable: ['panel', 'cam07'],
+ },
+ severe: {
+ reliable: ['thermal', 'replay'],
+ unreliable: ['panel', 'cam01', 'cam03', 'cam07'],
+ },
+ }[tier];
+ const effects = {
+ tier,
+ chromaticAberration: tier === 'normal' ? 0 : tier === 'light' ? 0.08 : tier === 'medium' ? 0.16 : 0.24,
+ timecodeJitter: tier === 'medium' || tier === 'severe',
+ edgeGhosting: tier !== 'normal',
+ protocolGlyphDropout: tier === 'severe',
+ audioDropout: tier === 'medium' || tier === 'severe',
+ reliableVerificationPaths: reliability.reliable,
+ unreliableVerificationPaths: reliability.unreliable,
+ };
+ return effects;
+}
+
+__exports_src_contamination_js["getContaminationTier"] = getContaminationTier;
+__exports_src_contamination_js["createContaminationState"] = createContaminationState;
+__exports_src_contamination_js["changeContamination"] = changeContamination;
+__exports_src_contamination_js["applyDecisionContamination"] = applyDecisionContamination;
+__exports_src_contamination_js["deriveContaminationEffects"] = deriveContaminationEffects;
+}
+var getContaminationTier = __exports_src_contamination_js["getContaminationTier"];
+var createContaminationState = __exports_src_contamination_js["createContaminationState"];
+var changeContamination = __exports_src_contamination_js["changeContamination"];
+var applyDecisionContamination = __exports_src_contamination_js["applyDecisionContamination"];
+var deriveContaminationEffects = __exports_src_contamination_js["deriveContaminationEffects"];
+
+// --- src/debriefTimeline.js ---
+var __exports_src_debriefTimeline_js = {};
+{
+function timelineItem(type, entry) {
+ return { type, ...entry, sequence: Number(entry.sequence || 0) };
+}
+
+function buildDebriefTimeline({ decisions = [], eventHistory = [], contaminationHistory = [] } = {}) {
+ const timeline = [
+ ...decisions.map(entry => timelineItem('decision', entry)),
+ ...eventHistory.map(entry => timelineItem('event-chain', entry)),
+ ...contaminationHistory.map(entry => timelineItem('contamination', entry)),
+ ].sort((a, b) => a.sequence - b.sequence);
+ const correct = decisions.filter(item => item.correct).length;
+ const wrong = decisions.length - correct;
+ const peakContamination = contaminationHistory.reduce(
+ (peak, item) => Math.max(peak, Number(item.value || 0)),
+ 0,
+ );
+ return {
+ timeline,
+ summary: {
+ decisions: decisions.length,
+ correct,
+ wrong,
+ accuracy: decisions.length ? correct / decisions.length : 0,
+ peakContamination,
+ eventStages: eventHistory.length,
+ },
+ };
+}
+
+function matchesEnding(ending, result) {
+ const condition = ending.condition || ending.conditions || {};
+ if (condition.requiredFlag && !(result.flags || []).includes(condition.requiredFlag)) return false;
+ if (condition.minContamination != null && result.contamination < condition.minContamination) return false;
+ if (condition.maxContamination != null && result.contamination > condition.maxContamination) return false;
+ if (condition.minAccuracy != null && result.accuracy < condition.minAccuracy) return false;
+ if (condition.maxAccuracy != null && result.accuracy > condition.maxAccuracy) return false;
+ return true;
+}
+
+function selectNightEnding(endings = [], result = {}) {
+ return [...endings]
+ .filter(ending => matchesEnding(ending, result))
+ .sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0) || a.id.localeCompare(b.id))[0] ?? null;
+}
+
+__exports_src_debriefTimeline_js["buildDebriefTimeline"] = buildDebriefTimeline;
+__exports_src_debriefTimeline_js["selectNightEnding"] = selectNightEnding;
+}
+var buildDebriefTimeline = __exports_src_debriefTimeline_js["buildDebriefTimeline"];
+var selectNightEnding = __exports_src_debriefTimeline_js["selectNightEnding"];
+
+// --- src/nightInteraction.js ---
+var __exports_src_nightInteraction_js = {};
+{
+
+
+
+const CATEGORIES = Object.freeze(['person', 'quantity', 'space', 'time', 'device', 'dynamic']);
+const HIGH_RISK_COSTS = Object.freeze({ emergencyStop: 15, restart: 10, lockdownFloor: 12 });
+
+function clone(value) {
+ if (Array.isArray(value)) return value.map(clone);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]));
+ }
+ return value;
+}
+
+function appendDecision(state, decision) {
+ const next = clone(state);
+ const decisions = next.night.decisions || [];
+ const sequence = Number(next.night.timelineSequence || 0) + 1;
+ next.night.timelineSequence = sequence;
+ decisions.push({ sequence, ...decision });
+ next.night.decisions = decisions;
+ return next;
+}
+
+function openProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = 'protocolQuery';
+ next.night.protocolQuery = clone(next.night.activeProtocols || []);
+ return next;
+}
+
+function closeProtocolQuery(state) {
+ const next = clone(state);
+ next.night.overlay = null;
+ return next;
+}
+
+function verifyCurrentIdentity(state) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity') {
+ return { state, accepted: false, reason: 'not-identity-round' };
+ }
+ const evidence = shift.evidence?.cameras?.cam01?.[0];
+ if (!evidence) return { state, accepted: false, reason: 'identity-evidence-missing' };
+ const next = clone(state);
+ const discovered = next.investigation.discoveredEvidence || [];
+ if (!discovered.some(item => item.id === evidence.id)) discovered.push(clone(evidence));
+ next.investigation.discoveredEvidence = discovered;
+ next.lastFeedback = `核验结果:${evidence.observation}`;
+ return { state: next, accepted: true, evidence: clone(evidence) };
+}
+
+function resolveIdentityDecision(state, choice) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'identity' || !['release', 'reject'].includes(choice)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-identity-decision' };
+ }
+ const expected = shift.decision === 'anomaly' ? 'reject' : 'release';
+ const correct = choice === expected;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: `identity:${choice}`,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.roundType = 'quick';
+ next.lastFeedback = correct
+ ? (choice === 'release' ? '身份一致,准予放行' : '身份冲突,拒绝通行')
+ : '身份判断错误,污染已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct };
+}
+
+function classifyCurrentShift(state, category) {
+ const shift = state?.night?.currentShift;
+ if (!shift || !CATEGORIES.includes(category)) {
+ return { state, accepted: false, correct: false, reason: 'invalid-classification' };
+ }
+ const correct = shift.category === category;
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'classification',
+ classification: category,
+ correct,
+ });
+ next.contamination = applyDecisionContamination(next.contamination, {
+ correct,
+ contentId: shift.id,
+ contaminationEffects: shift.contaminationEffects,
+ });
+ next.night.overlay = null;
+ next.night.roundType = shift.roundType === 'highRisk' || shift.highRisk ? 'highRisk' : 'quick';
+ next.lastFeedback = correct ? `分类确认:${category}` : `分类不符:${category}`;
+ return { state: next, accepted: true, correct };
+}
+
+function acceptedHighRiskAction(shift) {
+ if (shift.resolutionAction === 'emergencyStop') return 'emergencyStop';
+ if (shift.resolutionAction === 'restart') return 'restart';
+ return 'lockdownFloor';
+}
+
+function resolveCurrentHighRisk(state, action) {
+ const shift = state?.night?.currentShift;
+ if (!shift || state?.night?.roundType !== 'highRisk') {
+ return { state, accepted: false, correct: false, reason: 'not-high-risk' };
+ }
+ const highRisk = createHighRiskState({ power: state.power });
+ highRisk.nextShiftModifiers = clone(state.night.nextShiftModifiers || []);
+ const result = resolveHighRiskAction(highRisk, {
+ id: shift.id,
+ acceptedActions: [acceptedHighRiskAction(shift)],
+ costs: HIGH_RISK_COSTS,
+ successModifier: `resolved:${shift.id}`,
+ wrongModifiers: {
+ emergencyStop: 'power-grid-stress',
+ restart: 'control-reliability-down',
+ lockdownFloor: 'camera-delay',
+ },
+ }, action);
+ if (!result.accepted) return { ...result, state };
+ let next = appendDecision(state, {
+ contentId: shift.id,
+ choice: 'highRisk',
+ action,
+ correct: result.correct,
+ });
+ next.power = result.state.power;
+ next.investigation.power = result.state.power;
+ next.night.nextShiftModifiers = result.state.nextShiftModifiers;
+ next.night.roundType = 'quick';
+ next.lastFeedback = result.correct ? '高危处置完成' : '处置失误已影响后续班次';
+ next.gameOver = false;
+ return { state: next, accepted: true, correct: result.correct };
+}
+
+function createNightDebrief(state, endings = []) {
+ const report = buildDebriefTimeline({
+ decisions: state?.night?.decisions || [],
+ eventHistory: state?.night?.eventChainHistory || Object.values(state?.night?.eventChains || {}).flatMap(chain => chain.history || []),
+ contaminationHistory: state?.contamination?.history || [],
+ });
+ const eventChainFlags = state?.night?.eventChainFlags || [];
+ const nextShiftModifiers = state?.night?.nextShiftModifiers || [];
+ return {
+ ...report,
+ nextShiftModifiers: [...nextShiftModifiers],
+ ending: selectNightEnding(endings, {
+ flags: eventChainFlags,
+ contamination: Number(state?.contamination?.value || 0),
+ accuracy: report.summary.accuracy,
+ }),
+ };
+}
+
+__exports_src_nightInteraction_js["openProtocolQuery"] = openProtocolQuery;
+__exports_src_nightInteraction_js["closeProtocolQuery"] = closeProtocolQuery;
+__exports_src_nightInteraction_js["verifyCurrentIdentity"] = verifyCurrentIdentity;
+__exports_src_nightInteraction_js["resolveIdentityDecision"] = resolveIdentityDecision;
+__exports_src_nightInteraction_js["classifyCurrentShift"] = classifyCurrentShift;
+__exports_src_nightInteraction_js["resolveCurrentHighRisk"] = resolveCurrentHighRisk;
+__exports_src_nightInteraction_js["createNightDebrief"] = createNightDebrief;
+}
+var openProtocolQuery = __exports_src_nightInteraction_js["openProtocolQuery"];
+var closeProtocolQuery = __exports_src_nightInteraction_js["closeProtocolQuery"];
+var verifyCurrentIdentity = __exports_src_nightInteraction_js["verifyCurrentIdentity"];
+var resolveIdentityDecision = __exports_src_nightInteraction_js["resolveIdentityDecision"];
+var classifyCurrentShift = __exports_src_nightInteraction_js["classifyCurrentShift"];
+var resolveCurrentHighRisk = __exports_src_nightInteraction_js["resolveCurrentHighRisk"];
+var createNightDebrief = __exports_src_nightInteraction_js["createNightDebrief"];
// --- src/anomalyContent.js ---
+var __exports_src_anomalyContent_js = {};
+{
/**
* anomalyContent.js — 异常内容模式定义与结构化数据
*
@@ -772,8 +1476,33 @@ function getAnomalyCctvStates() {
return [...states];
}
+__exports_src_anomalyContent_js["ANOMALY_CONTENTS"] = ANOMALY_CONTENTS;
+__exports_src_anomalyContent_js["findAnomalyContent"] = findAnomalyContent;
+__exports_src_anomalyContent_js["getAllAnomalyContents"] = getAllAnomalyContents;
+__exports_src_anomalyContent_js["isDataConsistent"] = isDataConsistent;
+__exports_src_anomalyContent_js["getConflictFields"] = getConflictFields;
+__exports_src_anomalyContent_js["NORMAL_VARIANTS"] = NORMAL_VARIANTS;
+__exports_src_anomalyContent_js["pickNormalVariant"] = pickNormalVariant;
+__exports_src_anomalyContent_js["getAnomalyCctvState"] = getAnomalyCctvState;
+__exports_src_anomalyContent_js["getAnomaliesByCctvState"] = getAnomaliesByCctvState;
+__exports_src_anomalyContent_js["getNormalCctvStates"] = getNormalCctvStates;
+__exports_src_anomalyContent_js["getAnomalyCctvStates"] = getAnomalyCctvStates;
+}
+var ANOMALY_CONTENTS = __exports_src_anomalyContent_js["ANOMALY_CONTENTS"];
+var findAnomalyContent = __exports_src_anomalyContent_js["findAnomalyContent"];
+var getAllAnomalyContents = __exports_src_anomalyContent_js["getAllAnomalyContents"];
+var isDataConsistent = __exports_src_anomalyContent_js["isDataConsistent"];
+var getConflictFields = __exports_src_anomalyContent_js["getConflictFields"];
+var NORMAL_VARIANTS = __exports_src_anomalyContent_js["NORMAL_VARIANTS"];
+var pickNormalVariant = __exports_src_anomalyContent_js["pickNormalVariant"];
+var getAnomalyCctvState = __exports_src_anomalyContent_js["getAnomalyCctvState"];
+var getAnomaliesByCctvState = __exports_src_anomalyContent_js["getAnomaliesByCctvState"];
+var getNormalCctvStates = __exports_src_anomalyContent_js["getNormalCctvStates"];
+var getAnomalyCctvStates = __exports_src_anomalyContent_js["getAnomalyCctvStates"];
// --- src/visualState.js ---
+var __exports_src_visualState_js = {};
+{
/**
* visualState.js — 驱动 CCTV 视觉状态的核心映射
*
@@ -905,11 +1634,35 @@ function deriveVisualState(state) {
};
}
+__exports_src_visualState_js["getAnomalyResolutionAction"] = getAnomalyResolutionAction;
+__exports_src_visualState_js["deriveVisualState"] = deriveVisualState;
+}
+var getAnomalyResolutionAction = __exports_src_visualState_js["getAnomalyResolutionAction"];
+var deriveVisualState = __exports_src_visualState_js["deriveVisualState"];
// --- src/state.js ---
+var __exports_src_state_js = {};
+{
+
+
+
+function createNightState() {
+ return {
+ activeProtocols: [],
+ currentShift: null,
+ roundType: 'quick',
+ shiftIndex: 0,
+ decisions: [],
+ eventChains: {},
+ eventChainFlags: [],
+ eventChainHistory: [],
+ timelineSequence: 0,
+ nextShiftModifiers: [],
+ };
+}
function createInitialState() {
const c = CONFIG.initial;
@@ -922,6 +1675,9 @@ function createInitialState() {
power: c.power,
stability: c.stability,
anomalyLevel: c.anomalyLevel,
+ contamination: createContaminationState(),
+ night: createNightState(),
+ investigation: createInvestigationState({ power: c.power }),
passengers: c.passengers,
gameOver: c.gameOver,
result: 'playing',
@@ -1106,8 +1862,31 @@ function recordFailure(state) {
return next;
}
+__exports_src_state_js["createInitialState"] = createInitialState;
+__exports_src_state_js["cloneState"] = cloneState;
+__exports_src_state_js["appendLog"] = appendLog;
+__exports_src_state_js["clamp"] = clamp;
+__exports_src_state_js["checkFailure"] = checkFailure;
+__exports_src_state_js["saveSnapshot"] = saveSnapshot;
+__exports_src_state_js["reviveFromAd"] = reviveFromAd;
+__exports_src_state_js["tickState"] = tickState;
+__exports_src_state_js["recordSuccessfulShift"] = recordSuccessfulShift;
+__exports_src_state_js["recordFailure"] = recordFailure;
+}
+var createInitialState = __exports_src_state_js["createInitialState"];
+var cloneState = __exports_src_state_js["cloneState"];
+var appendLog = __exports_src_state_js["appendLog"];
+var clamp = __exports_src_state_js["clamp"];
+var checkFailure = __exports_src_state_js["checkFailure"];
+var saveSnapshot = __exports_src_state_js["saveSnapshot"];
+var reviveFromAd = __exports_src_state_js["reviveFromAd"];
+var tickState = __exports_src_state_js["tickState"];
+var recordSuccessfulShift = __exports_src_state_js["recordSuccessfulShift"];
+var recordFailure = __exports_src_state_js["recordFailure"];
// --- src/incidentDecision.js ---
+var __exports_src_incidentDecision_js = {};
+{
function openInspection(state, options) {
@@ -1220,11 +1999,22 @@ function expireInspection(state) {
return { state: checkFailure(next), timedOut: true };
}
+__exports_src_incidentDecision_js["openInspection"] = openInspection;
+__exports_src_incidentDecision_js["submitInspection"] = submitInspection;
+__exports_src_incidentDecision_js["expireInspection"] = expireInspection;
+}
+var openInspection = __exports_src_incidentDecision_js["openInspection"];
+var submitInspection = __exports_src_incidentDecision_js["submitInspection"];
+var expireInspection = __exports_src_incidentDecision_js["expireInspection"];
// --- src/events.js ---
+var __exports_src_events_js = {};
+{
+const skinHiddenLogLookup = getHiddenLog;
+
/**
* 从皮肤数据动态构建异常事件数组
*/
@@ -1297,7 +2087,7 @@ function applyAnomaly(state, id) {
next.anomaliesTriggeredTotal = (next.anomaliesTriggeredTotal ?? 0) + 1;
next.maxAnomalySeverity = Math.max(next.maxAnomalySeverity ?? 0, event.severity);
// 添加关联隐藏日志(不重复)
- const raw = getHiddenLog(id);
+ const raw = skinHiddenLogLookup(id);
if (raw && !next.hiddenLogs.some(h => h.id === id + '_log')) {
next.hiddenLogs.push({ id: id + '_log', title: raw.title, content: raw.content, locked: true });
next = appendLog(next, 'info', t('ui.hiddenLogCaptured', { title: raw.title }));
@@ -1323,7 +2113,7 @@ const _buildHiddenLogsMap = () => {
const map = {};
const anomalies = getAnomalies();
for (const a of anomalies) {
- const hl = getHiddenLog(a.id);
+ const hl = skinHiddenLogLookup(a.id);
if (hl) {
map[a.id] = { id: `${a.id}_log`, title: hl.title, content: hl.content };
}
@@ -1333,8 +2123,21 @@ const _buildHiddenLogsMap = () => {
const HIDDEN_LOGS = _buildHiddenLogsMap();
+__exports_src_events_js["ANOMALIES"] = ANOMALIES;
+__exports_src_events_js["findAnomaly"] = findAnomaly;
+__exports_src_events_js["applyAnomaly"] = applyAnomaly;
+__exports_src_events_js["pickNextAnomaly"] = pickNextAnomaly;
+__exports_src_events_js["HIDDEN_LOGS"] = HIDDEN_LOGS;
+}
+var ANOMALIES = __exports_src_events_js["ANOMALIES"];
+var findAnomaly = __exports_src_events_js["findAnomaly"];
+var applyAnomaly = __exports_src_events_js["applyAnomaly"];
+var pickNextAnomaly = __exports_src_events_js["pickNextAnomaly"];
+var HIDDEN_LOGS = __exports_src_events_js["HIDDEN_LOGS"];
// --- src/actions.js ---
+var __exports_src_actions_js = {};
+{
@@ -1535,19 +2338,216 @@ function getAvailableActions() {
return ACTION_IDS.map(id => ({ id, label: actionLabel(id) }));
}
+__exports_src_actions_js["performAction"] = performAction;
+__exports_src_actions_js["getAvailableActions"] = getAvailableActions;
+}
+var performAction = __exports_src_actions_js["performAction"];
+var getAvailableActions = __exports_src_actions_js["getAvailableActions"];
+
+// --- src/nightScheduler.js ---
+var __exports_src_nightScheduler_js = {};
+{
+
+
+
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function requireContentList(content, key) {
+ const list = content?.[key];
+ if (!Array.isArray(list) || list.length === 0) {
+ throw new Error(`V5 night scheduler requires non-empty ${key}`);
+ }
+ return list;
+}
+
+function pick(list, random) {
+ const value = Number(random());
+ const normalized = Number.isFinite(value) ? Math.max(0, Math.min(0.999999999999, value)) : 0;
+ return list[Math.floor(normalized * list.length)];
+}
+
+const NEXT_SHIFT_MODIFIER_VISUALS = Object.freeze({
+ duplicate_feed: '14_duplicate_subject',
+ floor_13_bleed: '16_wrong_floor',
+ unreliable_cam07: '10_signal_lost',
+});
+
+function installShift(state, shift, shiftKind, shiftIndex, activeProtocols, eventMeta = null) {
+ const next = clone(state);
+ const protocols = clone(activeProtocols);
+ const pendingModifiers = [...(next.night.nextShiftModifiers || [])];
+ const modifierVisualState = pendingModifiers
+ .map(modifier => NEXT_SHIFT_MODIFIER_VISUALS[modifier])
+ .find(Boolean);
+ next.night.activeProtocols = protocols;
+ next.night.currentShift = {
+ ...clone(shift),
+ ...(modifierVisualState ? { visualState: modifierVisualState } : {}),
+ ...(pendingModifiers.length ? { appliedModifiers: pendingModifiers } : {}),
+ shiftKind,
+ activeProtocols: clone(protocols),
+ ...(eventMeta ? {
+ eventChainId: eventMeta.chainId,
+ eventChainStep: eventMeta.stepId,
+ } : {}),
+ };
+ next.night.nextShiftModifiers = [];
+ next.night.roundType = shift.roundType || 'quick';
+ next.night.shiftIndex = shiftIndex;
+ next.investigation = createInvestigationState({ power: next.power });
+ return next;
+}
+
+function initialiseEventChains(state, content, random) {
+ if (!Array.isArray(content?.eventChains) || content.eventChains.length === 0) return state;
+ const next = clone(state);
+ const chainState = createEventChainState(content.eventChains);
+ next.night.eventChains = chainState.chains;
+ next.night.eventChainFlags = chainState.flags;
+ next.night.eventChainHistory = chainState.history;
+ next.night.activeEventChainId = pick(content.eventChains, random).id;
+ return next;
+}
+
+function getActiveChainStep(state, content) {
+ if (Number(state?.tutorialStep || 0) < 4) return null;
+ const chainId = state?.night?.activeEventChainId;
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ const progress = state?.night?.eventChains?.[chainId];
+ if (!chain || !progress || progress.completed) return null;
+ const step = chain.steps?.[progress.stepIndex];
+ if (!step) return null;
+ const shift = [...(content.normalShifts || []), ...(content.anomalies || [])]
+ .find(item => item.id === step.contentId);
+ return shift ? { chain, progress, step, shift } : null;
+}
+
+function createNightSchedule(state, content, options = {}) {
+ const normalShifts = requireContentList(content, 'normalShifts');
+ const anomalies = requireContentList(content, 'anomalies');
+ const protocols = requireContentList(content, 'protocols');
+ const random = options.random || Math.random;
+ const firstShift = pick(normalShifts, random);
+ const activeProtocols = generateNightProtocols({
+ protocols,
+ shifts: [...normalShifts, ...anomalies],
+ count: options.protocolCount ?? 3,
+ random,
+ });
+ const scheduled = installShift(state, firstShift, 'normal', 0, activeProtocols);
+ return initialiseEventChains(scheduled, content, random);
+}
+
+function scheduleNextNightShift(state, content, options = {}) {
+ requireContentList(content, 'normalShifts');
+ requireContentList(content, 'anomalies');
+ const random = options.random || Math.random;
+ const nextIndex = Number(state?.night?.shiftIndex || 0) + 1;
+ const activeProtocols = state?.night?.activeProtocols?.length
+ ? state.night.activeProtocols
+ : requireContentList(content, 'protocols');
+ const chainStep = getActiveChainStep(state, content);
+ if (chainStep) {
+ return installShift(
+ state,
+ chainStep.shift,
+ chainStep.shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ nextIndex,
+ activeProtocols,
+ { chainId: chainStep.chain.id, stepId: chainStep.step.id },
+ );
+ }
+ const shiftKind = nextIndex % 2 === 0 ? 'normal' : 'anomaly';
+ const shift = pick(content[shiftKind === 'normal' ? 'normalShifts' : 'anomalies'], random);
+ return installShift(state, shift, shiftKind, nextIndex, activeProtocols);
+}
+
+function advanceCurrentNightEventChain(state, content, outcome) {
+ if (Number(state?.tutorialStep || 0) < 4) return { state, advanced: false };
+ const chainId = state?.night?.activeEventChainId;
+ if (!chainId || !state?.night?.eventChains?.[chainId]) return { state, advanced: false };
+ const chain = content?.eventChains?.find(item => item.id === chainId);
+ if (!chain) return { state, advanced: false };
+ const chainState = {
+ chains: state.night.eventChains,
+ flags: state.night.eventChainFlags || [],
+ history: state.night.eventChainHistory || [],
+ };
+ const result = advanceEventChain(chainState, chain, outcome);
+ const next = clone(state);
+ let timelineSequence = Number(next.night.timelineSequence || 0);
+ const eventHistory = result.state.history.map(item => {
+ if (Number.isFinite(Number(item.sequence))) return item;
+ timelineSequence += 1;
+ return { ...item, sequence: timelineSequence };
+ });
+ next.night.timelineSequence = timelineSequence;
+ next.night.eventChains = {
+ ...next.night.eventChains,
+ [chainId]: {
+ ...result.state.chains[chainId],
+ history: eventHistory.filter(item => item.chainId === chainId),
+ },
+ };
+ next.night.eventChainFlags = result.state.flags;
+ next.night.eventChainHistory = eventHistory;
+ if (result.completed) next.night.activeEventChainId = null;
+ for (const consequence of result.consequences || []) {
+ if (Number(consequence.contaminationDelta || 0) !== 0) {
+ next.contamination = changeContamination(
+ next.contamination,
+ Number(consequence.contaminationDelta),
+ `event-chain:${chainId}`,
+ );
+ const history = next.contamination.history || [];
+ if (history.length > 0 && !Number.isFinite(Number(history.at(-1).sequence))) {
+ next.night.timelineSequence += 1;
+ history[history.length - 1] = {
+ ...history.at(-1),
+ sequence: next.night.timelineSequence,
+ };
+ next.contamination.history = history;
+ }
+ }
+ if (consequence.nextShiftModifier) {
+ next.night.nextShiftModifiers = [
+ ...(next.night.nextShiftModifiers || []),
+ consequence.nextShiftModifier,
+ ];
+ }
+ }
+ return { state: next, advanced: true, completed: Boolean(result.completed), result };
+}
+
+__exports_src_nightScheduler_js["createNightSchedule"] = createNightSchedule;
+__exports_src_nightScheduler_js["scheduleNextNightShift"] = scheduleNextNightShift;
+__exports_src_nightScheduler_js["advanceCurrentNightEventChain"] = advanceCurrentNightEventChain;
+}
+var createNightSchedule = __exports_src_nightScheduler_js["createNightSchedule"];
+var scheduleNextNightShift = __exports_src_nightScheduler_js["scheduleNextNightShift"];
+var advanceCurrentNightEventChain = __exports_src_nightScheduler_js["advanceCurrentNightEventChain"];
// --- src/runtimeSession.js ---
+var __exports_src_runtimeSession_js = {};
+{
+
-function createRuntimeSession() {
+function createRuntimeSession(options = {}) {
+ const initialState = createInitialState();
return {
- state: createInitialState(),
+ state: options.content
+ ? createNightSchedule(initialState, options.content, options)
+ : initialState,
nextAnomalyAt: CONFIG.anomaly.firstTriggerAt,
};
}
-function restartRuntimeSession(previousSession = null) {
- const session = createRuntimeSession();
+function restartRuntimeSession(previousSession = null, options = {}) {
+ const session = createRuntimeSession(options);
const previous = previousSession?.state;
if (!previous) return session;
@@ -1570,8 +2570,19 @@ function scheduleNextAnomalyAfterRevive(elapsed) {
return elapsed + CONFIG.anomaly.cooldownMin;
}
+__exports_src_runtimeSession_js["createRuntimeSession"] = createRuntimeSession;
+__exports_src_runtimeSession_js["restartRuntimeSession"] = restartRuntimeSession;
+__exports_src_runtimeSession_js["scheduleNextAnomalyAfterTrigger"] = scheduleNextAnomalyAfterTrigger;
+__exports_src_runtimeSession_js["scheduleNextAnomalyAfterRevive"] = scheduleNextAnomalyAfterRevive;
+}
+var createRuntimeSession = __exports_src_runtimeSession_js["createRuntimeSession"];
+var restartRuntimeSession = __exports_src_runtimeSession_js["restartRuntimeSession"];
+var scheduleNextAnomalyAfterTrigger = __exports_src_runtimeSession_js["scheduleNextAnomalyAfterTrigger"];
+var scheduleNextAnomalyAfterRevive = __exports_src_runtimeSession_js["scheduleNextAnomalyAfterRevive"];
// --- src/rewardGuard.js ---
+var __exports_src_rewardGuard_js = {};
+{
function shouldApplyReward(meta, currentRunToken, kind, state) {
if (meta?.context?.runToken !== currentRunToken || !state) return false;
@@ -1595,8 +2606,13 @@ function shouldApplyReward(meta, currentRunToken, kind, state) {
return false;
}
+__exports_src_rewardGuard_js["shouldApplyReward"] = shouldApplyReward;
+}
+var shouldApplyReward = __exports_src_rewardGuard_js["shouldApplyReward"];
// --- src/firstRunGuidance.js ---
+var __exports_src_firstRunGuidance_js = {};
+{
function getOperatorCue(state, nextAnomalyAt) {
const elapsed = Math.max(0, Math.floor(state?.elapsed ?? 0));
const firstAnomalySeen = (state?.anomaliesTriggeredTotal ?? 0) > 0;
@@ -1617,8 +2633,13 @@ function getOperatorCue(state, nextAnomalyAt) {
return '对得上就放行,对不上就封锁。';
}
+__exports_src_firstRunGuidance_js["getOperatorCue"] = getOperatorCue;
+}
+var getOperatorCue = __exports_src_firstRunGuidance_js["getOperatorCue"];
// --- platform/canvasLabels.js ---
+var __exports_platform_canvasLabels_js = {};
+{
function getCanvasLabels() {
const skin = getSkin();
@@ -1632,7 +2653,7 @@ function getCanvasLabels() {
actionPanel: canvas.actionPanel || '操作面板',
logPanel: canvas.logPanel || '系统日志',
failureTitle: canvas.failureTitle || '系统崩溃',
- failureEyebrow: canvas.failureEyebrow || 'SYSTEM FAILURE',
+ failureEyebrow: canvas.failureEyebrow || '系统故障',
revive: t('ui.viewAd'),
restart: t('ui.restart'),
revealTruth: t('ui.revealTruth'),
@@ -1665,8 +2686,19 @@ function getCanvasDirectionLabel(value) {
return labels[value] || value;
}
+__exports_platform_canvasLabels_js["getCanvasLabels"] = getCanvasLabels;
+__exports_platform_canvasLabels_js["getCanvasDecodedMonitorText"] = getCanvasDecodedMonitorText;
+__exports_platform_canvasLabels_js["getCanvasDoorLabel"] = getCanvasDoorLabel;
+__exports_platform_canvasLabels_js["getCanvasDirectionLabel"] = getCanvasDirectionLabel;
+}
+var getCanvasLabels = __exports_platform_canvasLabels_js["getCanvasLabels"];
+var getCanvasDecodedMonitorText = __exports_platform_canvasLabels_js["getCanvasDecodedMonitorText"];
+var getCanvasDoorLabel = __exports_platform_canvasLabels_js["getCanvasDoorLabel"];
+var getCanvasDirectionLabel = __exports_platform_canvasLabels_js["getCanvasDirectionLabel"];
// --- platform/canvasAssets.js ---
+var __exports_platform_canvasAssets_js = {};
+{
const CCTV_STATE_IDS = Object.freeze([
'00_idle_closed', '01_door_open', '02_door_opening', '03_door_closing',
'04_moving_up', '05_moving_down', '06_power_low', '07_power_outage',
@@ -1676,6 +2708,22 @@ const CCTV_STATE_IDS = Object.freeze([
'20_threat_high', '21_maintenance_mode', '22_system_reboot', '23_cooldown_safe',
]);
+const CCTV_STATE_ALIASES = Object.freeze({
+ // V5 内容描述“重复主体”,现有移动素材以影子主体表现同一类空间入侵;保留内容 ID,显式复用已发布图。
+ '14_duplicate_subject': '14_shadow_inside',
+});
+
+const V5_CCTV_ASSETS = Object.freeze({
+ protocolStart: 'visual/cctv/v5_00_protocol_start_mobile.png',
+ quick: 'visual/cctv/v5_01_quick_mobile.png',
+ investigation: 'visual/cctv/v5_02_investigation_mobile.png',
+ identity: 'visual/cctv/v5_03_identity_mobile.png',
+ classification: 'visual/cctv/v5_04_classification_mobile.png',
+ highRisk: 'visual/cctv/v5_05_high_risk_mobile.png',
+ protocolQuery: 'visual/cctv/v5_06_protocol_query_mobile.png',
+ debrief: 'visual/cctv/v5_07_debrief_mobile.png',
+});
+
const BUTTON_ASSETS = Object.freeze({
default: 'visual/buttons/btn_close_default.png',
recommended: 'visual/buttons/btn_up_recommended.png',
@@ -1698,7 +2746,11 @@ const OVERLAY_ASSETS = Object.freeze({
function getCanvasVisualAssetManifest() {
return {
- cctv: Object.fromEntries(CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`])),
+ cctv: Object.fromEntries([
+ ...CCTV_STATE_IDS.map(id => [id, `visual/cctv/${id}_mobile.png`]),
+ ...Object.entries(CCTV_STATE_ALIASES).map(([id, target]) => [id, `visual/cctv/${target}_mobile.png`]),
+ ]),
+ v5Cctv: { ...V5_CCTV_ASSETS },
buttons: { ...BUTTON_ASSETS },
overlays: { ...OVERLAY_ASSETS },
};
@@ -1729,6 +2781,7 @@ function createCanvasAssetStore(imageFactory) {
function preload() {
for (const path of Object.values(manifest.cctv)) load(path);
+ for (const path of Object.values(manifest.v5Cctv)) load(path);
for (const path of Object.values(manifest.buttons)) load(path);
for (const path of Object.values(manifest.overlays)) load(path);
}
@@ -1742,6 +2795,7 @@ function createCanvasAssetStore(imageFactory) {
manifest,
preload,
getCctv: stateId => get(manifest.cctv[stateId] || manifest.cctv['00_idle_closed']),
+ getV5Cctv: screenId => get(manifest.v5Cctv[screenId] || manifest.v5Cctv.quick),
getButton: kind => get(manifest.buttons[kind] || manifest.buttons.default),
getOverlay: kind => get(manifest.overlays[kind]),
getStatus: () => ({
@@ -1752,8 +2806,15 @@ function createCanvasAssetStore(imageFactory) {
};
}
+__exports_platform_canvasAssets_js["getCanvasVisualAssetManifest"] = getCanvasVisualAssetManifest;
+__exports_platform_canvasAssets_js["createCanvasAssetStore"] = createCanvasAssetStore;
+}
+var getCanvasVisualAssetManifest = __exports_platform_canvasAssets_js["getCanvasVisualAssetManifest"];
+var createCanvasAssetStore = __exports_platform_canvasAssets_js["createCanvasAssetStore"];
// --- platform/miniGameClock.js ---
+var __exports_platform_miniGameClock_js = {};
+{
function createMiniGameClock(now = () => Date.now()) {
let started = false;
let paused = false;
@@ -1793,8 +2854,13 @@ function createMiniGameClock(now = () => Date.now()) {
};
}
+__exports_platform_miniGameClock_js["createMiniGameClock"] = createMiniGameClock;
+}
+var createMiniGameClock = __exports_platform_miniGameClock_js["createMiniGameClock"];
// --- platform/cctvMotion.js ---
+var __exports_platform_cctvMotion_js = {};
+{
const ACTION_DURATIONS = Object.freeze({
openDoor: 1000,
@@ -1954,8 +3020,13 @@ function createCctvMotionController(now = () => Date.now()) {
return { startAction, startAnomaly, sample, pause, resume, reset };
}
+__exports_platform_cctvMotion_js["createCctvMotionController"] = createCctvMotionController;
+}
+var createCctvMotionController = __exports_platform_cctvMotion_js["createCctvMotionController"];
// --- platform/miniGameAudio.js ---
+var __exports_platform_miniGameAudio_js = {};
+{
const SOURCES = Object.freeze({
click: 'audio/click.wav',
anomaly: 'audio/anomaly.wav',
@@ -1967,8 +3038,37 @@ const SOURCES = Object.freeze({
wrong: 'audio/wrong.wav',
});
+const MUSIC_SOURCES = Object.freeze({
+ calm: 'audio/bgm-night-shift-loop.wav',
+ pressure: 'audio/bgm-anomaly-pressure-loop.wav',
+});
+
+const V5_FEEDBACK_PROFILES = Object.freeze({
+ camera: Object.freeze({ cue: 'click', haptic: 'light' }),
+ 'tool:thermal': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'tool:replay': Object.freeze({ cue: 'motor', haptic: 'light' }),
+ 'tool:protocol': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'protocol:close': Object.freeze({ cue: 'release', haptic: 'light' }),
+ 'identity:verify': Object.freeze({ cue: 'boot', haptic: 'light' }),
+ 'identity:correct': Object.freeze({ cue: 'release', haptic: 'medium' }),
+ 'identity:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'classification:enter': Object.freeze({ cue: 'anomaly', haptic: 'medium' }),
+ 'classification:correct': Object.freeze({ cue: 'lockdown', haptic: 'medium' }),
+ 'classification:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+ 'highRisk:correct': Object.freeze({ cue: 'lockdown', haptic: 'heavy' }),
+ 'highRisk:wrong': Object.freeze({ cue: 'wrong', haptic: 'heavy' }),
+});
+
+function getV5FeedbackProfile(kind) {
+ const profile = V5_FEEDBACK_PROFILES[kind] || V5_FEEDBACK_PROFILES.camera;
+ return { ...profile };
+}
+
function createMiniGameAudio(api) {
const contexts = new Map();
+ let musicContext = null;
+ let musicState = null;
+ let musicPaused = true;
let muted = false;
function getContext(cue) {
@@ -1983,7 +3083,27 @@ function createMiniGameAudio(api) {
return context;
}
- return {
+ function getMusicContext() {
+ if (musicContext) return musicContext;
+ if (!api || typeof api.createInnerAudioContext !== 'function') return null;
+ musicContext = api.createInnerAudioContext();
+ musicContext.autoplay = false;
+ musicContext.loop = true;
+ musicContext.volume = 0.12;
+ return musicContext;
+ }
+
+ function safePlay(context) {
+ try {
+ const result = context?.play?.();
+ result?.catch?.(() => {});
+ return Boolean(context && typeof context.play === 'function');
+ } catch {
+ return false;
+ }
+ }
+
+ const controller = {
play(cue) {
if (muted || !SOURCES[cue]) return false;
const context = getContext(cue);
@@ -1991,33 +3111,82 @@ function createMiniGameAudio(api) {
try {
context.stop?.();
context.seek?.(0);
- const result = context.play();
- result?.catch?.(() => {});
- return true;
+ return safePlay(context);
} catch {
return false;
}
},
+ setMusicState(nextState) {
+ if (!MUSIC_SOURCES[nextState]) return false;
+ musicState = nextState;
+ if (muted) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ if (context.src !== MUSIC_SOURCES[nextState]) {
+ context.stop?.();
+ context.src = MUSIC_SOURCES[nextState];
+ context.loop = true;
+ context.volume = nextState === 'pressure' ? 0.10 : 0.12;
+ context.seek?.(0);
+ }
+ musicPaused = false;
+ return safePlay(context);
+ },
+ pauseMusic() {
+ musicContext?.pause?.();
+ musicPaused = true;
+ },
+ resumeMusic() {
+ if (muted || !musicState || !musicPaused) return false;
+ const context = getMusicContext();
+ if (!context) return false;
+ context.src = MUSIC_SOURCES[musicState];
+ context.loop = true;
+ context.volume = musicState === 'pressure' ? 0.10 : 0.12;
+ musicPaused = false;
+ return safePlay(context);
+ },
+ stopMusic() {
+ musicContext?.stop?.();
+ musicPaused = true;
+ },
+ getMusicState() {
+ return musicState;
+ },
stopAll() {
for (const context of contexts.values()) context.stop?.();
+ controller.stopMusic();
},
destroy() {
for (const context of contexts.values()) context.destroy?.();
contexts.clear();
+ musicContext?.destroy?.();
+ musicContext = null;
+ musicState = null;
+ musicPaused = true;
},
setMuted(value) {
muted = Boolean(value);
- if (muted) this.stopAll();
+ if (muted) controller.stopAll();
return muted;
},
isMuted() {
return muted;
},
};
+
+ return controller;
}
+__exports_platform_miniGameAudio_js["getV5FeedbackProfile"] = getV5FeedbackProfile;
+__exports_platform_miniGameAudio_js["createMiniGameAudio"] = createMiniGameAudio;
+}
+var getV5FeedbackProfile = __exports_platform_miniGameAudio_js["getV5FeedbackProfile"];
+var createMiniGameAudio = __exports_platform_miniGameAudio_js["createMiniGameAudio"];
// --- platform/douyinIntegration.js ---
+var __exports_platform_douyinIntegration_js = {};
+{
function bindMiniGameLifecycle(api, handlers = {}) {
const onPause = () => handlers.onPause?.();
const onResume = (options) => handlers.onResume?.(options);
@@ -2079,8 +3248,17 @@ function navigateToDouyinSidebar(api) {
});
}
+__exports_platform_douyinIntegration_js["bindMiniGameLifecycle"] = bindMiniGameLifecycle;
+__exports_platform_douyinIntegration_js["checkDouyinSidebar"] = checkDouyinSidebar;
+__exports_platform_douyinIntegration_js["navigateToDouyinSidebar"] = navigateToDouyinSidebar;
+}
+var bindMiniGameLifecycle = __exports_platform_douyinIntegration_js["bindMiniGameLifecycle"];
+var checkDouyinSidebar = __exports_platform_douyinIntegration_js["checkDouyinSidebar"];
+var navigateToDouyinSidebar = __exports_platform_douyinIntegration_js["navigateToDouyinSidebar"];
// --- platform/canvasRenderer.js ---
+var __exports_platform_canvasRenderer_js = {};
+{
/**
* canvasRenderer.js — Canvas 渲染器
*
@@ -2136,24 +3314,38 @@ function getCanvasViewportMetrics(systemInfo = {}) {
}
function getCanvasLayout(height = 1334, safeTop = 0) {
- // V4:一块大监控、三项读数、一个双选任务。禁止把桌面后台缩进手机。
+ // V5:协议与 CAM 使用原生 Canvas 行;大 CCTV 仍是最大单一表面。
const topbar = { x: 14, y: 12 + safeTop, w: 722, h: 76 };
- const rule = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
- const monitorH = Math.max(520, Math.min(880, height - safeTop - 644));
- const monitor = { x: 14, y: 170 + safeTop, w: 722, h: monitorH };
+ const protocolBar = { x: 14, y: 96 + safeTop, w: 722, h: 66 };
+ const cameraTabs = {
+ x: 14, y: 170 + safeTop, w: 722, h: 54, gap: 8,
+ hitY: 146 + safeTop, hitH: 94,
+ };
+ // Match the two official V5 portrait frames: 360×640 uses a compact 230px CCTV,
+ // while 393×852 spends the extra vertical room on a 360px CCTV. Interpolation
+ // keeps intermediate phones fluid without creating a dead area below the monitor.
+ const monitorH = Math.max(479, Math.min(687, 479 + (height - 1334) * (208 / 291)));
+ const monitor = { x: 14, y: 232 + safeTop, w: 722, h: monitorH };
const readings = { x: 14, y: monitor.y + monitor.h + 12, w: 722, h: 108 };
+ const tools = {
+ x: 14, y: readings.y + readings.h + 12, w: 722, h: 76, gap: 10,
+ hitY: readings.y + readings.h + 2, hitH: 100,
+ };
const actions = {
- x: 14, y: readings.y + readings.h + 12, w: 722, h: 220,
- columns: 2, gap: 14, buttonH: 164,
+ x: 14, y: tools.y + tools.h + 12, w: 722, h: 146,
+ columns: 2, gap: 14, buttonH: 104,
};
actions.startY = actions.y + 42;
actions.buttonW = (actions.w - 32 - actions.gap) / 2;
const feedbackY = actions.y + actions.h + 12;
return {
topbar,
- rule,
+ rule: protocolBar,
+ protocolBar,
+ cameraTabs,
monitor,
readings,
+ tools,
actions,
feedback: { x: 14, y: feedbackY, w: 722, h: Math.max(90, height - feedbackY - 18) },
};
@@ -2307,15 +3499,64 @@ function getRuleCopy(state) {
return t('ui.coreRule');
}
-function drawRuleStrip(state) {
- const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).rule;
+function getCanvasProtocolItems(state) {
+ return (state?.night?.activeProtocols || []).slice(0, 3).map(protocol => ({
+ id: protocol.id,
+ category: protocol.category || 'protocol',
+ text: protocol.text || protocol.id,
+ }));
+}
+
+function getCanvasProtocolSummary(protocols = []) {
+ return protocols.map((protocol, index) => {
+ const text = String(protocol?.text || protocol?.id || '');
+ const compact = text.length > 14 ? `${text.slice(0, 14)}…` : text;
+ return `${index + 1}.${compact}`;
+ }).join(' ');
+}
+
+function drawProtocolBar(state) {
+ const { x, y, w, h } = getCanvasLayout(DH, safeInsetTop).protocolBar;
drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.40)');
- const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
- ctx.fillStyle = guided ? COLORS.amber : COLORS.green;
+ const protocols = getCanvasProtocolItems(state);
+ ctx.fillStyle = protocols.length ? COLORS.amber : COLORS.green;
ctx.fillRect(x + 6, y + 6, 7, h - 12);
ctx.fillStyle = COLORS.text;
- ctx.font = '26px "Microsoft YaHei", sans-serif';
- ctx.fillText(getRuleCopy(state), x + 30, y + 43, w - 142);
+ ctx.font = 'bold 20px "Microsoft YaHei", sans-serif';
+ ctx.fillText('夜班协议', x + 28, y + 26);
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const guided = Number(state.tutorialStep || 0) < 2 && state.inspection?.status === 'pending';
+ const summary = guided || !protocols.length
+ ? getRuleCopy(state)
+ : getCanvasProtocolSummary(protocols);
+ ctx.fillText(summary, x + 28, y + 52, w - 52);
+}
+
+function getCanvasCameraTabs(state) {
+ const cameras = Object.keys(state?.night?.currentShift?.evidence?.cameras || {});
+ const activeCamera = state?.investigation?.activeCamera || 'cam01';
+ return ['cam01', 'cam03', 'cam07']
+ .filter(id => cameras.includes(id))
+ .map(id => ({ id, label: id.replace('cam', 'CAM-'), active: id === activeCamera }));
+}
+
+function drawCameraTabs(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const tabs = getCanvasCameraTabs(state);
+ if (!tabs.length) return;
+ const tabW = (layout.w - layout.gap * (tabs.length - 1)) / tabs.length;
+ tabs.forEach((tab, index) => {
+ const x = layout.x + index * (tabW + layout.gap);
+ roundRect(x, layout.y, tabW, layout.h, 2,
+ tab.active ? '#17352a' : '#101314',
+ tab.active ? 'rgba(121,214,163,0.78)' : 'rgba(195,200,190,0.24)');
+ ctx.fillStyle = tab.active ? COLORS.green : COLORS.muted;
+ ctx.font = 'bold 22px Consolas, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(tab.label, x + tabW / 2, layout.y + 35);
+ drawPressShade(x, layout.y, tabW, layout.h, getPressDepth(tab.id));
+ });
+ ctx.textAlign = 'left';
}
function getCanvasReadings(state, motion = null) {
@@ -2376,7 +3617,14 @@ function drawFeedback(state) {
ctx.textAlign = 'right';
ctx.fillText(pending ? '等待判断' : `安全 ${Math.round(state.stability || 0)}%`, x + w - 24, y + 42);
ctx.textAlign = 'left';
- const barY = y + Math.min(h - 22, 62);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '20px "Microsoft YaHei", sans-serif';
+ const power = Math.max(0, Math.min(100, Math.round(Number(state.power) || 0)));
+ const contamination = Math.max(0, Math.min(100, Math.round(Number(state.contamination?.value) || 0)));
+ ctx.fillText(`电力 ${power}%`, x + 24, y + 70);
+ ctx.fillStyle = contamination >= 51 ? COLORS.red : contamination >= 26 ? COLORS.amber : COLORS.cyan;
+ ctx.fillText(`污染 ${contamination}%`, x + 168, y + 70);
+ const barY = y + Math.min(h - 22, 78);
roundRect(x + 24, barY, w - 48, 12, 2, 'rgba(255,255,255,0.08)');
if (!pending) {
roundRect(x + 24, barY, Math.max(0, (w - 48) * ((state.stability || 0) / 100)), 12, 2,
@@ -2513,12 +3761,22 @@ function getCanvasCctvTreatment(cctvState = '00_idle_closed') {
const entity = ['13_entity_near', '14_shadow_inside', '15_anomaly_wandering'].includes(cctvState);
const threat = ['08_emergency_stop', '09_door_jammed', '16_wrong_floor', '20_threat_high'].includes(cctvState);
const darkness = cctvState === '07_power_outage' ? 0.62 : cctvState === '10_signal_lost' ? 0.38 : 0;
+ const calm = cctvState === '19_stabilized' || cctvState === '23_cooldown_safe';
const tint = threat
? 'rgba(255,77,109,0.16)'
- : cctvState === '19_stabilized' || cctvState === '23_cooldown_safe'
+ : calm
? 'rgba(97,255,190,0.12)'
: 'rgba(97,255,190,0.05)';
- return { tint, darkness, entity, glitch, threat };
+ const border = threat
+ ? 'rgba(255,77,109,0.85)'
+ : glitch
+ ? 'rgba(225,168,75,0.62)'
+ : entity
+ ? 'rgba(178,132,255,0.62)'
+ : calm
+ ? 'rgba(97,255,190,0.52)'
+ : 'rgba(121,214,163,0.34)';
+ return { tint, darkness, entity, glitch, threat, border };
}
function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight = 420) {
@@ -2537,34 +3795,109 @@ function drawImageCover(image, x, y, w, h, fallbackWidth = 720, fallbackHeight =
ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
}
-function drawCctvImage(image, x, y, w, h) {
- const sourceW = Number(image.width || image.naturalWidth) || 720;
- const sourceH = Number(image.height || image.naturalHeight) || 420;
- // 生产状态图顶部/底部烘焙了英文诊断和固定HUD;先裁掉答案区,再按主画面 cover。
- const cropTop = Math.min(58, sourceH * 0.14);
- const cropBottom = Math.min(30, sourceH * 0.08);
- const usableH = sourceH - cropTop - cropBottom;
- const sourceRatio = sourceW / usableH;
- const targetRatio = w / h;
- let sx = 0, sy = cropTop, sw = sourceW, sh = usableH;
- if (sourceRatio > targetRatio) {
- sw = usableH * targetRatio;
- sx = (sourceW - sw) / 2;
- } else {
- sh = sourceW / targetRatio;
- sy = cropTop + (usableH - sh) / 2;
+function drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime = 0) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+ ctx.clip();
+
+ // 真实监控感:扫描线 + 镜头暗角 + 轻微色偏。三层均只作用于 CCTV,不污染按钮和协议。
+ const scanlines = assetStore?.getOverlay('scanlines');
+ const vignette = assetStore?.getOverlay('vignette');
+ const frame = assetStore?.getOverlay('frame');
+ if (scanlines) {
+ ctx.globalAlpha = treatment.threat ? 0.38 : 0.24;
+ ctx.drawImage(scanlines, x, y, w, h);
}
- ctx.drawImage(image, sx, sy, sw, sh, x, y, w, h);
+ if (vignette) {
+ ctx.globalAlpha = treatment.threat ? 0.82 : 0.62;
+ ctx.drawImage(vignette, x, y, w, h);
+ }
+ ctx.globalAlpha = 1;
+
+ if (treatment.tint) {
+ ctx.fillStyle = treatment.tint;
+ ctx.fillRect(x, y, w, h);
+ }
+
+ // 慢速 CRT 扫描带:比静态噪点更容易让玩家感到“摄像头正在工作”。
+ const phase = ((frameTime / 1800) % 1 + 1) % 1;
+ const beamY = y + phase * h;
+ const beam = ctx.createLinearGradient(x, beamY - 30, x, beamY + 30);
+ beam.addColorStop(0, 'rgba(97,255,190,0)');
+ beam.addColorStop(0.5, treatment.threat ? 'rgba(255,77,109,0.30)' : 'rgba(97,255,190,0.22)');
+ beam.addColorStop(1, 'rgba(97,255,190,0)');
+ ctx.fillStyle = beam;
+ ctx.fillRect(x, beamY - 30, w, 60);
+
+ // 录制指示器与镜头角标是运行时 HUD,不泄露答案,只建立“夜班监控”语境。
+ const pulse = 0.72 + Math.sin(frameTime / 170) * 0.22;
+ ctx.globalAlpha = pulse;
+ ctx.fillStyle = treatment.threat ? COLORS.red : '#ff5d67';
+ ctx.beginPath();
+ ctx.arc(x + 20, y + 22, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#e4e8df';
+ ctx.font = 'bold 16px Consolas, monospace';
+ ctx.fillText('REC', x + 32, y + 28);
+ ctx.fillStyle = treatment.threat ? '#ff9a9f' : '#b4c4bb';
+ ctx.font = '14px Consolas, monospace';
+ const activeCamera = String(state?.investigation?.activeCamera || 'cam01').toUpperCase().replace('CAM', 'CAM-');
+ ctx.fillText(`${activeCamera} // NIGHT WATCH`, x + 20, y + h - 18);
+
+ // 角框比一整圈发光边框更克制,但会让 CCTV 从“普通图片”变成监控窗口。
+ if (frame) {
+ ctx.globalAlpha = 0.72;
+ ctx.drawImage(frame, x, y, w, h);
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = treatment.border || 'rgba(121,214,163,0.44)';
+ ctx.lineWidth = treatment.threat ? 3 + Math.max(0, Math.sin(frameTime / 130)) : 2;
+ ctx.strokeRect(x + 2, y + 2, w - 4, h - 4);
+
+ if (treatment.threat) {
+ ctx.globalAlpha = 0.72 + Math.sin(frameTime / 110) * 0.18;
+ ctx.strokeStyle = COLORS.red;
+ ctx.lineWidth = 4;
+ ctx.strokeRect(x + 8, y + 8, w - 16, h - 16);
+ ctx.globalAlpha = 1;
+ }
+ ctx.restore();
+}
+
+function drawCctvImage(image, x, y, w, h) {
+ // CCTV 窗口保持主布局尺寸;素材 cover 铺满窗口:无拉伸变形、无黑边、不叠第二层背景。
+ // 高竖屏窗口下中央裁掉两侧边缘,轿厢主体始终居中完整。
+ drawImageCover(image, x, y, w, h);
+}
+
+// V5 阶段场景映射:夜班各回合使用交接包对应场景,运动/异常瞬时态仍回退 24 状态机图。
+function getV5CctvScreenId(state) {
+ if (state?.night?.overlay === 'protocolQuery') return 'protocolQuery';
+ const roundType = state?.night?.roundType;
+ if (!state?.night?.currentShift) return null;
+ return {
+ quick: 'quick',
+ investigation: 'investigation',
+ identity: 'identity',
+ classification: 'classification',
+ highRisk: 'highRisk',
+ }[roundType] || null;
}
function drawCctvScene(state, x, y, w, h, motion = null) {
if (h <= 20) return;
const baseVisual = deriveVisualState(state);
const frameTime = Number(motion?.frameTime ?? Date.now());
- const cctvState = motion?.cctvState || baseVisual.cctvState;
+ const cctvState = motion?.cctvState
+ || state?.night?.currentShift?.visualState
+ || baseVisual.cctvState;
const visual = { ...baseVisual, cctvState, glitch: baseVisual.glitch || Number(motion?.glitchAlpha || 0) > 0 };
const treatment = getCanvasCctvTreatment(cctvState);
- const sceneImage = assetStore?.getCctv(cctvState);
+ const v5ScreenId = motion?.active ? null : getV5CctvScreenId(state);
+ const sceneImage = (v5ScreenId ? assetStore?.getV5Cctv(v5ScreenId) : null)
+ || assetStore?.getCctv(cctvState);
if (sceneImage) {
ctx.save();
@@ -2589,18 +3922,32 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
drawCctvImage(sceneImage, drawX, drawY, drawW, drawH);
ctx.globalAlpha = 1;
+ drawCctvAtmosphere(state, x, y, w, h, treatment, frameTime);
+
// 状态图已内置基础监控纹理,只叠加真正随时间变化的警报与干扰。
const pendingDecision = state.inspection?.status === 'pending';
const alert = treatment.threat && !pendingDecision ? assetStore.getOverlay('redAlert') : null;
const glitchOverlay = treatment.glitch ? assetStore.getOverlay('glitch') : null;
- const sweep = state.inspection?.status === 'pending' ? assetStore.getOverlay('sweep') : null;
- for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36], [sweep, 0.28]]) {
+ for (const [image, alpha] of [[alert, 0.72], [glitchOverlay, 0.36]]) {
if (!image) continue;
ctx.globalAlpha = alpha;
ctx.drawImage(image, x, y, w, h);
}
ctx.globalAlpha = 1;
+ // 待判定扫描光束随时间自上而下扫过,给出“系统正在核对”的活体感。
+ const sweep = pendingDecision ? assetStore.getOverlay('sweep') : null;
+ if (sweep) {
+ const sweepH = Math.max(96, Math.floor(h * 0.38));
+ const sweepPhase = (frameTime / 2100) % 1.45;
+ if (sweepPhase <= 1) {
+ const sweepY = y - sweepH + sweepPhase * (h + sweepH * 2);
+ ctx.globalAlpha = 0.34;
+ ctx.drawImage(sweep, x, sweepY, w, sweepH);
+ ctx.globalAlpha = 1;
+ }
+ }
+
const glitchAlpha = Math.max(0, Math.min(1, Number(motion?.glitchAlpha || 0)));
if (glitchAlpha > 0) {
ctx.globalAlpha = glitchAlpha;
@@ -2628,23 +3975,8 @@ function drawCctvScene(state, x, y, w, h, motion = null) {
ctx.fillStyle = scanGradient;
ctx.fillRect(x, scanY - 24, w, 48);
- // 实体式顶部遮光罩:覆盖素材中烘焙的 07 / STABILIZED / 英文诊断,而不是再贴一块中央黑卡。
- const hudShade = ctx.createLinearGradient(0, y, 0, y + 104);
- hudShade.addColorStop(0, '#020707');
- hudShade.addColorStop(0.82, '#020707');
- hudShade.addColorStop(1, 'rgba(2,7,7,0)');
- ctx.fillStyle = hudShade;
- ctx.fillRect(x, y, w, 112);
- ctx.strokeStyle = 'rgba(121,214,163,0.22)';
- ctx.beginPath();
- ctx.moveTo(x, y + 96);
- ctx.lineTo(x + w, y + 96);
- ctx.stroke();
-
- // 状态图含固定英文诊断与固定楼层;源图已裁掉烘焙答案区,这里只叠加中文运行时状态。
+ // 替换图无烘焙 HUD;只绘制运行时楼层和状态标签,不覆盖电梯主体。
const inspectionPending = state.inspection?.status === 'pending';
- const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
- const floorDiscrepancy = ['phantom_floor', 'floor_jump', 'negative_floor'].includes(activeId);
const neutralBorder = inspectionPending ? 'rgba(195,200,190,0.34)' : treatment.border;
ctx.strokeStyle = neutralBorder;
ctx.globalAlpha = 0.72;
@@ -2844,12 +4176,65 @@ function getCanvasActionButtons(state) {
return operations;
}
+const TOOL_LABELS = {
+ thermal: '热源扫描',
+ replay: '三秒回放',
+ protocol: '夜班协议',
+};
+
+function getCanvasToolButtons(state) {
+ const investigation = state?.investigation || {};
+ return ['thermal', 'replay', 'protocol'].map(id => {
+ const tool = investigation.tools?.[id] || {};
+ const remaining = tool.remaining;
+ const unlimited = !Number.isFinite(remaining);
+ const disabled = !unlimited && (remaining <= 0 || (investigation.power ?? 0) < (tool.powerCost || 0));
+ return {
+ id,
+ label: TOOL_LABELS[id],
+ meta: unlimited ? '不限次' : `${remaining || 0}次 · ${tool.powerCost || 0}电`,
+ disabled,
+ };
+ });
+}
+
+const ROUND_ACTIONS = {
+ quick: [
+ { id: 'release', label: '放行', sublabel: '画面数据一致', decision: 'normal' },
+ { id: 'lockdown', label: '封锁', sublabel: '发现任意矛盾', decision: 'anomaly' },
+ ],
+ investigation: [
+ { id: 'markSuspicion', label: '标记疑点', sublabel: '保留当前证据' },
+ { id: 'enterClassification', label: '进入分类', sublabel: '提交异常类型' },
+ ],
+ identity: [
+ { id: 'identityRelease', label: '放行', sublabel: '身份一致' },
+ { id: 'identityReject', label: '拒绝', sublabel: '身份冲突' },
+ { id: 'identityVerify', label: '核验', sublabel: '查看胸牌与权限' },
+ ],
+ classification: [
+ { id: 'classify:person', label: '人物', sublabel: '身份/外观' },
+ { id: 'classify:quantity', label: '数量', sublabel: '人数/载重' },
+ { id: 'classify:space', label: '空间', sublabel: '楼层/位置' },
+ { id: 'classify:time', label: '时间', sublabel: '时序/回放' },
+ { id: 'classify:device', label: '设备', sublabel: '信号/读数' },
+ { id: 'classify:dynamic', label: '动态', sublabel: '移动/变化' },
+ ],
+ highRisk: [
+ { id: 'highRisk:emergencyStop', label: '急停', sublabel: '消耗 15 电' },
+ { id: 'highRisk:restart', label: '重启', sublabel: '消耗 10 电' },
+ { id: 'highRisk:lockdownFloor', label: '封锁楼层', sublabel: '消耗 12 电' },
+ ],
+};
+
function getCanvasVisibleActionButtons(state) {
if (state.inspection?.status === 'pending') {
- return [
+ if (Number(state.tutorialStep || 0) < 2) return [
{ id: 'reportNormal', label: t('ui.reportNormal'), sublabel: '画面数据一致', decision: 'normal' },
{ id: 'reportAnomaly', label: t('ui.reportAnomaly'), sublabel: '发现任意矛盾', decision: 'anomaly' },
];
+ if (Number(state.tutorialStep || 0) === 3) return ROUND_ACTIONS.quick;
+ return ROUND_ACTIONS[state?.night?.roundType] || ROUND_ACTIONS.quick;
}
const activeId = typeof state.activeAnomaly === 'string' ? state.activeAnomaly : state.activeAnomaly?.id;
@@ -2860,6 +4245,28 @@ function getCanvasVisibleActionButtons(state) {
return [{ id: 'standby', label: t('ui.standby'), sublabel: '监控自动运行', disabled: true, wide: true }];
}
+function drawTools(state) {
+ const layout = getCanvasLayout(DH, safeInsetTop).tools;
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (layout.w - 24 - layout.gap * 2) / 3;
+ tools.forEach((tool, index) => {
+ const x = layout.x + 12 + index * (buttonW + layout.gap);
+ ctx.save();
+ if (tool.disabled) ctx.globalAlpha = 0.42;
+ roundRect(x, layout.y, buttonW, layout.h, 2, '#101716', tool.disabled ? COLORS.line : 'rgba(132,185,176,0.62)');
+ ctx.fillStyle = tool.disabled ? COLORS.muted : COLORS.cyan;
+ ctx.font = 'bold 22px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(tool.label, x + buttonW / 2, layout.y + 31);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = '18px "Microsoft YaHei", sans-serif';
+ ctx.fillText(tool.meta, x + buttonW / 2, layout.y + 58);
+ drawPressShade(x, layout.y, buttonW, layout.h, getPressDepth(tool.id));
+ ctx.restore();
+ });
+ ctx.textAlign = 'left';
+}
+
// ── 绘制操作按钮 ──
function drawActions(state) {
const layout = getCanvasLayout(DH, safeInsetTop).actions;
@@ -2870,8 +4277,8 @@ function drawActions(state) {
ctx.fillText(state.activeAnomaly && state.inspection?.status !== 'pending' ? '系统处置' : '当前判断', x + 24, y + 31);
const btns = getCanvasVisibleActionButtons(state);
- const columns = btns.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? w - 32 : (w - 32 - gap) / 2;
+ const columns = btns.length === 1 ? 1 : btns.length === 6 ? 6 : btns.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? w - 32 : (w - 32 - gap * (columns - 1)) / columns;
btns.forEach((btn, i) => {
ctx.save();
if (btn.disabled) ctx.globalAlpha = 0.48;
@@ -2903,17 +4310,18 @@ function drawActions(state) {
ctx.shadowBlur = btn.disabled ? 0 : 12;
ctx.fillStyle = accent;
ctx.beginPath();
- ctx.arc(bx + buttonW / 2, by + 31, 9, 0, Math.PI * 2);
+ ctx.arc(bx + buttonW / 2, by + 18, 7, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = COLORS.text;
- ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.font = 'bold 28px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
- ctx.fillText(btn.label, bx + buttonW / 2, by + 94);
+ ctx.fillText(btn.label, bx + buttonW / 2, by + 57);
ctx.fillStyle = '#b5b8b1';
- ctx.font = '24px "Microsoft YaHei", sans-serif';
- ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 132);
+ ctx.font = '19px "Microsoft YaHei", sans-serif';
+ ctx.fillText(btn.sublabel || '', bx + buttonW / 2, by + 86, buttonW - 20);
+ drawPressShade(bx, by, buttonW, buttonH, getPressDepth(btn.id));
const guidedIndex = Number(state.tutorialStep || 0);
const guided = (state.inspection?.status === 'pending'
@@ -2960,6 +4368,59 @@ function drawLogs(state) {
});
}
+function getCanvasOverlayCloseButton(height = 1334, safeTop = 0) {
+ const x = 55, w = 640, h = 430;
+ const y = Math.max(150 + safeTop, (height - h) / 2);
+ return { x: x + 32, y: y + h - 88, w: w - 64, h: 60 };
+}
+
+function getCanvasOverlayModel(state) {
+ if (state?.night?.overlay === 'protocolQuery') {
+ return {
+ type: 'protocolQuery',
+ title: '夜班协议查询',
+ lines: (state.night.protocolQuery || []).map(item => item.text || item.id),
+ action: 'closeOverlay',
+ };
+ }
+ if (state?.night?.overlay === 'debrief' && state.night.debrief) {
+ const { summary = {}, ending = {} } = state.night.debrief;
+ return {
+ type: 'debrief',
+ title: `局后复盘 · ${ending.name || '未决记录'}`,
+ lines: [
+ `判断 ${summary.decisions || 0} 次 · 准确率 ${Math.round((summary.accuracy || 0) * 100)}%`,
+ `污染峰值 ${summary.peakContamination || 0}`,
+ ending.summary || '',
+ ].filter(Boolean),
+ action: 'closeOverlay',
+ };
+ }
+ return null;
+}
+
+function drawNightOverlay(state) {
+ const model = getCanvasOverlayModel(state);
+ if (!model) return;
+ ctx.fillStyle = 'rgba(0,0,0,0.76)';
+ ctx.fillRect(0, 0, DW, DH);
+ const x = 55, w = 640, h = 430, y = Math.max(150 + safeInsetTop, (DH - h) / 2);
+ drawIndustrialPanel(x, y, w, h, 'rgba(225,168,75,0.72)');
+ ctx.fillStyle = COLORS.amber;
+ ctx.font = 'bold 34px "Microsoft YaHei", sans-serif';
+ ctx.fillText(model.title, x + 32, y + 58, w - 64);
+ ctx.fillStyle = COLORS.text;
+ ctx.font = '26px "Microsoft YaHei", sans-serif';
+ model.lines.forEach((line, index) => wrapText(`${index + 1}. ${line}`, x + 32, y + 112 + index * 62, w - 64, 32));
+ const closeButton = getCanvasOverlayCloseButton(DH, safeInsetTop);
+ roundRect(closeButton.x, closeButton.y, closeButton.w, closeButton.h, 3, '#17352a', 'rgba(121,214,163,0.72)');
+ ctx.fillStyle = COLORS.green;
+ ctx.font = 'bold 26px "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('返回监控', closeButton.x + closeButton.w / 2, closeButton.y + 39);
+ ctx.textAlign = 'left';
+}
+
// ── 绘制失败弹窗 ──
function drawFailureOverlay(state) {
if (!state.gameOver) return;
@@ -3180,11 +4641,42 @@ function wrapText(text, x, y, maxWidth, lineHeight) {
}
}
+// ── 按压反馈 ──
+const pressFx = new Map();
+const PRESS_FX_MS = 180;
+
+function noteCanvasPress(id) {
+ if (id) pressFx.set(id, Date.now());
+}
+
+function getPressDepth(id) {
+ const at = pressFx.get(id);
+ if (!Number.isFinite(at)) return 0;
+ const age = Date.now() - at;
+ if (age > PRESS_FX_MS) {
+ pressFx.delete(id);
+ return 0;
+ }
+ return 1 - age / PRESS_FX_MS;
+}
+
+function drawPressShade(x, y, w, h, depth) {
+ if (depth <= 0) return;
+ ctx.save();
+ ctx.globalAlpha = 0.3 * depth;
+ roundRect(x + 2, y + 2, w - 4, h - 4, 2, '#000000');
+ ctx.globalAlpha = 0.5 * depth;
+ ctx.strokeStyle = 'rgba(255,255,255,0.75)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(x + 5, y + 5, w - 10, h - 10);
+ ctx.restore();
+}
+
// ── 点击检测 ──
let clickHandlers = {};
function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
- const { onAdRevive, onRestart, onAction, onDecision, onToggleMute, onStart, onSidebar } = callbacks;
+ const { onAdRevive, onRestart, onAction, onDecision, onTool, onCameraSwitch, onToggleMute, onStart, onSidebar } = callbacks;
const inside = (rect) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
const muteControl = getCanvasMuteControl(DH, safeInsetTop, viewState.started !== false);
if (!state.gameOver && inside(muteControl)) {
@@ -3201,6 +4693,11 @@ function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
if (viewState.paused === true) return;
+ if (getCanvasOverlayModel(state)) {
+ if (inside(getCanvasOverlayCloseButton(DH, safeInsetTop))) onAction?.('closeOverlay');
+ return;
+ }
+
// 失败弹窗按钮检测
if (state.gameOver) {
const cardW = 640, cardH = 520;
@@ -3233,16 +4730,47 @@ function onCanvasClick(x, y, state, callbacks, viewState = { started: true }) {
return;
}
- // V4 双选任务点击检测,与绘制布局共用同一组按钮数据。
+ const cameraLayout = getCanvasLayout(DH, safeInsetTop).cameraTabs;
+ const cameraTabs = getCanvasCameraTabs(state);
+ const cameraHit = { ...cameraLayout, y: cameraLayout.hitY ?? cameraLayout.y, h: cameraLayout.hitH ?? cameraLayout.h };
+ if (cameraTabs.length && inside(cameraHit)) {
+ const tabW = (cameraLayout.w - cameraLayout.gap * (cameraTabs.length - 1)) / cameraTabs.length;
+ for (let index = 0; index < cameraTabs.length; index += 1) {
+ const tabX = cameraLayout.x + index * (tabW + cameraLayout.gap);
+ if (x >= tabX && x <= tabX + tabW) {
+ noteCanvasPress(cameraTabs[index].id);
+ onCameraSwitch?.(cameraTabs[index].id);
+ }
+ }
+ return;
+ }
+
+ const toolLayout = getCanvasLayout(DH, safeInsetTop).tools;
+ const toolHit = { ...toolLayout, y: toolLayout.hitY ?? toolLayout.y, h: toolLayout.hitH ?? toolLayout.h };
+ if (inside(toolHit)) {
+ const tools = getCanvasToolButtons(state);
+ const buttonW = (toolLayout.w - 24 - toolLayout.gap * 2) / 3;
+ for (let index = 0; index < tools.length; index += 1) {
+ const toolX = toolLayout.x + 12 + index * (buttonW + toolLayout.gap);
+ if (x >= toolX && x <= toolX + buttonW && !tools[index].disabled) {
+ noteCanvasPress(tools[index].id);
+ onTool?.(tools[index].id);
+ }
+ }
+ return;
+ }
+
+ // V5 动态任务点击检测,与绘制布局共用同一组按钮数据。
const layout = getCanvasLayout(DH, safeInsetTop).actions;
const buttons = getCanvasVisibleActionButtons(state);
- const columns = buttons.length === 1 ? 1 : 2;
- const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap) / 2;
+ const columns = buttons.length === 1 ? 1 : buttons.length === 6 ? 6 : buttons.length === 3 ? 3 : 2;
+ const buttonW = columns === 1 ? layout.w - 32 : (layout.w - 32 - layout.gap * (columns - 1)) / columns;
for (let i = 0; i < buttons.length; i += 1) {
const bx = layout.x + 16 + (i % columns) * (buttonW + layout.gap);
const by = layout.startY;
if (x >= bx && x <= bx + buttonW && y >= by && y <= by + layout.buttonH) {
if (buttons[i].disabled) return;
+ noteCanvasPress(buttons[i].id);
if (buttons[i].decision) {
onDecision?.(buttons[i].decision);
} else onAction?.(buttons[i].id);
@@ -3257,19 +4785,22 @@ function render(state, viewState = { started: true, paused: false }) {
drawBackground();
drawTopbar(state);
- drawRuleStrip(state);
+ drawProtocolBar(state);
+ drawCameraTabs(state);
drawMonitor(state, viewState.cctvMotion);
drawReadings(state, viewState.cctvMotion);
+ drawTools(state);
drawActions(state);
drawFeedback(state);
drawFailureOverlay(state);
+ drawNightOverlay(state);
if (viewState.started === false) drawStartOverlay(viewState);
else if (viewState.paused === true) drawPauseOverlay();
if (!state.gameOver) drawMuteControl(viewState);
}
// ── 初始化 ──
-function init(canvasEl, systemInfo = {}) {
+function init(canvasEl, systemInfo = {}, options = {}) {
canvas = canvasEl;
ctx = canvas.getContext('2d');
@@ -3282,20 +4813,73 @@ function init(canvasEl, systemInfo = {}) {
canvas.height = metrics.height;
scale = 1;
- const imageFactory = () => {
+ // 小游戏运行时优先 wx/tt createImage;浏览器验收 harness 通过 options.imageFactory 注入 DOM Image,
+ // 使发布 bundle 不含任何 document/window 引用。
+ const imageFactory = options.imageFactory || (() => {
if (typeof tt !== 'undefined' && typeof tt.createImage === 'function') return tt.createImage();
if (typeof wx !== 'undefined' && typeof wx.createImage === 'function') return wx.createImage();
if (typeof canvas.createImage === 'function') return canvas.createImage();
return null;
- };
+ });
assetStore = createCanvasAssetStore(imageFactory);
assetStore.preload();
return { width: DW, height: DH };
}
+__exports_platform_canvasRenderer_js["getCanvasViewportMetrics"] = getCanvasViewportMetrics;
+__exports_platform_canvasRenderer_js["getCanvasLayout"] = getCanvasLayout;
+__exports_platform_canvasRenderer_js["getCanvasStartControls"] = getCanvasStartControls;
+__exports_platform_canvasRenderer_js["getCanvasMuteControl"] = getCanvasMuteControl;
+__exports_platform_canvasRenderer_js["getCanvasStaticLabels"] = getCanvasStaticLabels;
+__exports_platform_canvasRenderer_js["getCanvasFailureOverlayCopy"] = getCanvasFailureOverlayCopy;
+__exports_platform_canvasRenderer_js["getCanvasProtocolItems"] = getCanvasProtocolItems;
+__exports_platform_canvasRenderer_js["getCanvasProtocolSummary"] = getCanvasProtocolSummary;
+__exports_platform_canvasRenderer_js["getCanvasCameraTabs"] = getCanvasCameraTabs;
+__exports_platform_canvasRenderer_js["getCanvasReadings"] = getCanvasReadings;
+__exports_platform_canvasRenderer_js["getCanvasStatusItems"] = getCanvasStatusItems;
+__exports_platform_canvasRenderer_js["getCanvasMeterBars"] = getCanvasMeterBars;
+__exports_platform_canvasRenderer_js["getCanvasCctvTreatment"] = getCanvasCctvTreatment;
+__exports_platform_canvasRenderer_js["getV5CctvScreenId"] = getV5CctvScreenId;
+__exports_platform_canvasRenderer_js["getCanvasActionButtons"] = getCanvasActionButtons;
+__exports_platform_canvasRenderer_js["getCanvasToolButtons"] = getCanvasToolButtons;
+__exports_platform_canvasRenderer_js["getCanvasVisibleActionButtons"] = getCanvasVisibleActionButtons;
+__exports_platform_canvasRenderer_js["getCanvasVisibleLogs"] = getCanvasVisibleLogs;
+__exports_platform_canvasRenderer_js["getCanvasOverlayCloseButton"] = getCanvasOverlayCloseButton;
+__exports_platform_canvasRenderer_js["getCanvasOverlayModel"] = getCanvasOverlayModel;
+__exports_platform_canvasRenderer_js["noteCanvasPress"] = noteCanvasPress;
+__exports_platform_canvasRenderer_js["onCanvasClick"] = onCanvasClick;
+__exports_platform_canvasRenderer_js["render"] = render;
+__exports_platform_canvasRenderer_js["init"] = init;
+}
+var getCanvasViewportMetrics = __exports_platform_canvasRenderer_js["getCanvasViewportMetrics"];
+var getCanvasLayout = __exports_platform_canvasRenderer_js["getCanvasLayout"];
+var getCanvasStartControls = __exports_platform_canvasRenderer_js["getCanvasStartControls"];
+var getCanvasMuteControl = __exports_platform_canvasRenderer_js["getCanvasMuteControl"];
+var getCanvasStaticLabels = __exports_platform_canvasRenderer_js["getCanvasStaticLabels"];
+var getCanvasFailureOverlayCopy = __exports_platform_canvasRenderer_js["getCanvasFailureOverlayCopy"];
+var getCanvasProtocolItems = __exports_platform_canvasRenderer_js["getCanvasProtocolItems"];
+var getCanvasProtocolSummary = __exports_platform_canvasRenderer_js["getCanvasProtocolSummary"];
+var getCanvasCameraTabs = __exports_platform_canvasRenderer_js["getCanvasCameraTabs"];
+var getCanvasReadings = __exports_platform_canvasRenderer_js["getCanvasReadings"];
+var getCanvasStatusItems = __exports_platform_canvasRenderer_js["getCanvasStatusItems"];
+var getCanvasMeterBars = __exports_platform_canvasRenderer_js["getCanvasMeterBars"];
+var getCanvasCctvTreatment = __exports_platform_canvasRenderer_js["getCanvasCctvTreatment"];
+var getV5CctvScreenId = __exports_platform_canvasRenderer_js["getV5CctvScreenId"];
+var getCanvasActionButtons = __exports_platform_canvasRenderer_js["getCanvasActionButtons"];
+var getCanvasToolButtons = __exports_platform_canvasRenderer_js["getCanvasToolButtons"];
+var getCanvasVisibleActionButtons = __exports_platform_canvasRenderer_js["getCanvasVisibleActionButtons"];
+var getCanvasVisibleLogs = __exports_platform_canvasRenderer_js["getCanvasVisibleLogs"];
+var getCanvasOverlayCloseButton = __exports_platform_canvasRenderer_js["getCanvasOverlayCloseButton"];
+var getCanvasOverlayModel = __exports_platform_canvasRenderer_js["getCanvasOverlayModel"];
+var noteCanvasPress = __exports_platform_canvasRenderer_js["noteCanvasPress"];
+var onCanvasClick = __exports_platform_canvasRenderer_js["onCanvasClick"];
+var render = __exports_platform_canvasRenderer_js["render"];
+var init = __exports_platform_canvasRenderer_js["init"];
// --- platform/miniGameRuntime.js ---
+var __exports_platform_miniGameRuntime_js = {};
+{
/**
* miniGameRuntime.js — 微信/抖音小游戏 Canvas 运行时入口
*
@@ -3422,6 +5006,11 @@ function startMiniGame() {
const vibrate = (type = 'light') => {
try { api.vibrateShort?.({ type }); } catch { /* optional haptics */ }
};
+ const playV5Feedback = (kind) => {
+ const profile = getV5FeedbackProfile(kind);
+ audio.play(profile.cue);
+ vibrate(profile.haptic);
+ };
const audioStorageKey = 'minigame_audio_muted_v1';
try {
audio.setMuted(api.getStorageSync?.(audioStorageKey) === true);
@@ -3434,7 +5023,7 @@ function startMiniGame() {
return available;
});
refreshSidebarAvailability();
- let session = createRuntimeSession();
+ let session = createRuntimeSession({ content: __V5_CONTENT__ });
let state = session.state;
let nextAnomalyAt = session.nextAnomalyAt;
let lastSnapshotAt = 0;
@@ -3456,6 +5045,7 @@ function startMiniGame() {
if (!lifecycleHidden) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
}
@@ -3524,6 +5114,9 @@ function startMiniGame() {
function toggleMute() {
const muted = audio.setMuted(!audio.isMuted());
+ if (!muted && clock.isStarted() && !state.gameOver && !lifecycleHidden && !adPauseActive) {
+ audio.resumeMusic() || audio.setMusicState(state.activeAnomaly ? 'pressure' : 'calm');
+ }
try {
api.setStorageSync?.(audioStorageKey, muted);
} catch {
@@ -3534,6 +5127,7 @@ function startMiniGame() {
function start() {
if (clock.isStarted()) return;
audio.play('boot');
+ audio.setMusicState('calm');
state = openInspection(state, {
id: `baseline-${runToken}`,
kind: 'normal',
@@ -3553,8 +5147,9 @@ function startMiniGame() {
function restart() {
runToken += 1;
audio.play('boot');
+ audio.setMusicState('calm');
clock.start();
- session = restartRuntimeSession({ state });
+ session = restartRuntimeSession({ state }, { content: __V5_CONTENT__ });
state = session.state;
cctvMotion.reset();
state = openInspection(state, {
@@ -3569,6 +5164,22 @@ function startMiniGame() {
failureRecorded = false;
}
+ function openScheduledNightInspection(nextState) {
+ const shift = nextState.night?.currentShift;
+ if (!shift) return nextState;
+ return openInspection(nextState, {
+ id: `night-${shift.id}-${nextState.night.shiftIndex}`,
+ kind: shift.shiftKind === 'anomaly' || shift.decision === 'anomaly' ? 'anomaly' : 'normal',
+ title: shift.name || shift.id,
+ duration: shift.duration ?? 10,
+ });
+ }
+
+ function scheduleFollowingNightShift(currentState, outcome) {
+ const advanced = advanceCurrentNightEventChain(currentState, __V5_CONTENT__, outcome);
+ return openScheduledNightInspection(scheduleNextNightShift(advanced.state, __V5_CONTENT__));
+ }
+
function resolveActiveAnomalyAutomatically(feedbackKey) {
if (!state.activeAnomaly) return false;
const automaticAction = getAnomalyResolutionAction(state.activeAnomaly);
@@ -3626,13 +5237,70 @@ function startMiniGame() {
}
// 教学第二班必须直接进入异常,不允许中间插入随机正常巡检。
const tutorialStep = Number(state.tutorialStep || 0);
+ if (tutorialStep === 4 && state.night?.activeEventChainId) {
+ state = openScheduledNightInspection(scheduleNextNightShift(state, __V5_CONTENT__));
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ return;
+ }
nextNormalInspectionAt = tutorialStep === 1
? Number.POSITIVE_INFINITY
: state.elapsed + (tutorialStep === 3 ? 2 : 4);
}
function handleAction(actionId) {
- if (state.gameOver) return;
+ if (state.gameOver && actionId !== 'closeOverlay') return;
+ if (actionId === 'closeOverlay') {
+ state = closeProtocolQuery(state);
+ playV5Feedback('protocol:close');
+ return;
+ }
+ if (actionId === 'identityVerify') {
+ const result = verifyCurrentIdentity(state);
+ if (!result.accepted) {
+ playV5Feedback('identity:wrong');
+ return;
+ }
+ state = result.state;
+ playV5Feedback('identity:verify');
+ return;
+ }
+ if (actionId === 'identityRelease' || actionId === 'identityReject') {
+ const result = resolveIdentityDecision(state, actionId === 'identityRelease' ? 'release' : 'reject');
+ if (!result.accepted) return;
+ playV5Feedback(`identity:${result.correct ? 'correct' : 'wrong'}`);
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ return;
+ }
+ if (actionId === 'enterClassification' || actionId === 'markSuspicion') {
+ state = {
+ ...state,
+ night: { ...state.night, roundType: 'classification' },
+ lastFeedback: '请选择异常分类',
+ };
+ playV5Feedback('classification:enter');
+ return;
+ }
+ if (actionId.startsWith('classify:')) {
+ const result = classifyCurrentShift(state, actionId.slice('classify:'.length));
+ if (!result.accepted) return;
+ state = result.state;
+ playV5Feedback(`classification:${result.correct ? 'correct' : 'wrong'}`);
+ if (state.night.roundType !== 'highRisk') {
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ }
+ return;
+ }
+ if (actionId.startsWith('highRisk:')) {
+ const result = resolveCurrentHighRisk(state, actionId.slice('highRisk:'.length));
+ if (!result.accepted) {
+ playV5Feedback('highRisk:wrong');
+ return;
+ }
+ state = scheduleFollowingNightShift(result.state, { correct: result.correct });
+ playV5Feedback(`highRisk:${result.correct ? 'correct' : 'wrong'}`);
+ return;
+ }
if (actionId === 'unlockHiddenLog') {
decodeAd({ runToken });
return;
@@ -3650,6 +5318,42 @@ function startMiniGame() {
}
}
+ function handleCameraSwitch(cameraId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = switchCamera(state.investigation, cameraId, {
+ ...shift,
+ cameras: Object.keys(shift.evidence?.cameras || {}),
+ });
+ if (!result.accepted) return;
+ state = { ...state, investigation: result.state };
+ playV5Feedback('camera');
+ }
+
+ function handleTool(toolId) {
+ const shift = state.night?.currentShift;
+ if (!shift) return;
+ const result = useInvestigationTool(state.investigation, toolId, shift);
+ if (!result.accepted) {
+ audio.play('wrong');
+ vibrate('heavy');
+ return;
+ }
+ const count = Array.isArray(result.discoveredEvidence)
+ ? result.discoveredEvidence.length
+ : result.discoveredEvidence ? 1 : 0;
+ state = {
+ ...state,
+ investigation: result.state,
+ power: result.state.power,
+ lastFeedback: toolId === 'protocol'
+ ? `已调取 ${count} 条当前夜班协议`
+ : `${toolId === 'thermal' ? '热源扫描' : '三秒回放'}发现 ${count} 条证据`,
+ };
+ if (toolId === 'protocol') state = openProtocolQuery(state);
+ playV5Feedback(`tool:${toolId}`);
+ }
+
function handleAd(kind) {
if (kind === 'truth') {
truthAd({ runToken });
@@ -3667,6 +5371,8 @@ function startMiniGame() {
onCanvasClick(x, y, state, {
onAction: handleAction,
onDecision: handleDecision,
+ onTool: handleTool,
+ onCameraSwitch: handleCameraSwitch,
onToggleMute: toggleMute,
onAdRevive: handleAd,
onRestart: restart,
@@ -3689,6 +5395,7 @@ function startMiniGame() {
if (!adPauseActive) {
clock.resume();
cctvMotion.resume();
+ if (clock.isStarted() && !state.gameOver) audio.resumeMusic();
}
},
});
@@ -3700,11 +5407,20 @@ function startMiniGame() {
for (let i = 0; i < delta; i += 1) {
state = tickState(state, 1);
if (!state.gameOver) {
+ const expiredNightShift = Number(state.tutorialStep || 0) >= 4
+ && Boolean(state.night?.activeEventChainId)
+ && Boolean(state.night?.currentShift?.id);
const expiredKind = state.inspection?.kind;
const expiry = expireInspection(state);
state = expiry.state;
if (expiry.timedOut) {
audio.play(expiry.coached ? 'wrong' : 'result');
+ if (expiredNightShift && !state.gameOver) {
+ state = scheduleFollowingNightShift(state, { correct: false });
+ nextNormalInspectionAt = Number.POSITIVE_INFINITY;
+ nextAnomalyAt = Number.POSITIVE_INFINITY;
+ continue;
+ }
if (expiredKind === 'anomaly' && state.activeAnomaly) {
resolveActiveAnomalyAutomatically('ui.autoResolutionTimeout');
}
@@ -3762,11 +5478,26 @@ function startMiniGame() {
}
if (state.gameOver && !failureRecorded) {
state = state.result === 'success' ? recordSuccessfulShift(state) : recordFailure(state);
+ state = {
+ ...state,
+ night: {
+ ...state.night,
+ overlay: 'debrief',
+ debrief: createNightDebrief(state, __V5_CONTENT__.endings),
+ },
+ };
audio.play('result');
failureRecorded = true;
}
}
+ if (state.gameOver) {
+ audio.stopMusic();
+ } else if (clock.isStarted() && !lifecycleHidden && !adPauseActive && !audio.isMuted()) {
+ const desiredMusic = state.activeAnomaly ? 'pressure' : 'calm';
+ if (audio.getMusicState() !== desiredMusic) audio.setMusicState(desiredMusic);
+ }
+
render(state, getViewState());
nextFrame(api, update);
}
@@ -3776,6 +5507,11 @@ function startMiniGame() {
return { canvas, getState: () => state, restart, start };
}
+__exports_platform_miniGameRuntime_js["createMiniGameRewardedAd"] = createMiniGameRewardedAd;
+__exports_platform_miniGameRuntime_js["startMiniGame"] = startMiniGame;
+}
+var createMiniGameRewardedAd = __exports_platform_miniGameRuntime_js["createMiniGameRewardedAd"];
+var startMiniGame = __exports_platform_miniGameRuntime_js["startMiniGame"];
// ── 平台入口 ──
diff --git a/wechat-minigame/game.json b/wechat-minigame/game.json
index f20c9b9..8a939e6 100644
--- a/wechat-minigame/game.json
+++ b/wechat-minigame/game.json
@@ -5,5 +5,10 @@
"request": 5000,
"connectSocket": 5000
},
- "subPackages": []
+ "subPackages": [
+ {
+ "root": "visual",
+ "name": "v5-visual"
+ }
+ ]
}
\ No newline at end of file
diff --git a/wechat-minigame/project.config.json b/wechat-minigame/project.config.json
index 637001a..60f8975 100644
--- a/wechat-minigame/project.config.json
+++ b/wechat-minigame/project.config.json
@@ -10,7 +10,7 @@
},
"compileType": "game",
"libVersion": "latest",
- "appid": "请替换为你的微信小游戏 AppID",
+ "appid": "touristappid",
"projectname": "MINIGAME",
"condition": {}
}
\ No newline at end of file
diff --git a/wechat-minigame/visual/buttons/btn_close_default.png b/wechat-minigame/visual/buttons/btn_close_default.png
index 1993989..82e81ae 100644
Binary files a/wechat-minigame/visual/buttons/btn_close_default.png and b/wechat-minigame/visual/buttons/btn_close_default.png differ
diff --git a/wechat-minigame/visual/buttons/btn_disabled.png b/wechat-minigame/visual/buttons/btn_disabled.png
index c7083e4..fa0469f 100644
Binary files a/wechat-minigame/visual/buttons/btn_disabled.png and b/wechat-minigame/visual/buttons/btn_disabled.png differ
diff --git a/wechat-minigame/visual/buttons/btn_log_secondary.png b/wechat-minigame/visual/buttons/btn_log_secondary.png
index c2103a0..082ae6f 100644
Binary files a/wechat-minigame/visual/buttons/btn_log_secondary.png and b/wechat-minigame/visual/buttons/btn_log_secondary.png differ
diff --git a/wechat-minigame/visual/buttons/btn_more_secondary.png b/wechat-minigame/visual/buttons/btn_more_secondary.png
index b43cf26..082ae6f 100644
Binary files a/wechat-minigame/visual/buttons/btn_more_secondary.png and b/wechat-minigame/visual/buttons/btn_more_secondary.png differ
diff --git a/wechat-minigame/visual/buttons/btn_pressed.png b/wechat-minigame/visual/buttons/btn_pressed.png
index 1cc7113..96e5d3a 100644
Binary files a/wechat-minigame/visual/buttons/btn_pressed.png and b/wechat-minigame/visual/buttons/btn_pressed.png differ
diff --git a/wechat-minigame/visual/buttons/btn_scan_default.png b/wechat-minigame/visual/buttons/btn_scan_default.png
index d65c589..82e81ae 100644
Binary files a/wechat-minigame/visual/buttons/btn_scan_default.png and b/wechat-minigame/visual/buttons/btn_scan_default.png differ
diff --git a/wechat-minigame/visual/buttons/btn_stop_danger.png b/wechat-minigame/visual/buttons/btn_stop_danger.png
index 48b005e..aee23ff 100644
Binary files a/wechat-minigame/visual/buttons/btn_stop_danger.png and b/wechat-minigame/visual/buttons/btn_stop_danger.png differ
diff --git a/wechat-minigame/visual/buttons/btn_up_recommended.png b/wechat-minigame/visual/buttons/btn_up_recommended.png
index d5cf7d7..6c2d43b 100644
Binary files a/wechat-minigame/visual/buttons/btn_up_recommended.png and b/wechat-minigame/visual/buttons/btn_up_recommended.png differ
diff --git a/wechat-minigame/visual/cctv/00_idle_closed_mobile.png b/wechat-minigame/visual/cctv/00_idle_closed_mobile.png
index 7c26666..c6aafd9 100644
Binary files a/wechat-minigame/visual/cctv/00_idle_closed_mobile.png and b/wechat-minigame/visual/cctv/00_idle_closed_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/01_door_open_mobile.png b/wechat-minigame/visual/cctv/01_door_open_mobile.png
index 20879cd..ea1721e 100644
Binary files a/wechat-minigame/visual/cctv/01_door_open_mobile.png and b/wechat-minigame/visual/cctv/01_door_open_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/02_door_opening_mobile.png b/wechat-minigame/visual/cctv/02_door_opening_mobile.png
index e57ea29..e683baa 100644
Binary files a/wechat-minigame/visual/cctv/02_door_opening_mobile.png and b/wechat-minigame/visual/cctv/02_door_opening_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/03_door_closing_mobile.png b/wechat-minigame/visual/cctv/03_door_closing_mobile.png
index 347a7a7..70d7787 100644
Binary files a/wechat-minigame/visual/cctv/03_door_closing_mobile.png and b/wechat-minigame/visual/cctv/03_door_closing_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/04_moving_up_mobile.png b/wechat-minigame/visual/cctv/04_moving_up_mobile.png
index 431d14f..5ce57d1 100644
Binary files a/wechat-minigame/visual/cctv/04_moving_up_mobile.png and b/wechat-minigame/visual/cctv/04_moving_up_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/05_moving_down_mobile.png b/wechat-minigame/visual/cctv/05_moving_down_mobile.png
index a6c65dc..a48904a 100644
Binary files a/wechat-minigame/visual/cctv/05_moving_down_mobile.png and b/wechat-minigame/visual/cctv/05_moving_down_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/06_power_low_mobile.png b/wechat-minigame/visual/cctv/06_power_low_mobile.png
index 471bc7c..395fcdc 100644
Binary files a/wechat-minigame/visual/cctv/06_power_low_mobile.png and b/wechat-minigame/visual/cctv/06_power_low_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/07_power_outage_mobile.png b/wechat-minigame/visual/cctv/07_power_outage_mobile.png
index d3c72e0..63e053d 100644
Binary files a/wechat-minigame/visual/cctv/07_power_outage_mobile.png and b/wechat-minigame/visual/cctv/07_power_outage_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/08_emergency_stop_mobile.png b/wechat-minigame/visual/cctv/08_emergency_stop_mobile.png
index 0319299..ea51248 100644
Binary files a/wechat-minigame/visual/cctv/08_emergency_stop_mobile.png and b/wechat-minigame/visual/cctv/08_emergency_stop_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/09_door_jammed_mobile.png b/wechat-minigame/visual/cctv/09_door_jammed_mobile.png
index b3d36e2..97a4111 100644
Binary files a/wechat-minigame/visual/cctv/09_door_jammed_mobile.png and b/wechat-minigame/visual/cctv/09_door_jammed_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/10_signal_lost_mobile.png b/wechat-minigame/visual/cctv/10_signal_lost_mobile.png
index 770c417..9d84f29 100644
Binary files a/wechat-minigame/visual/cctv/10_signal_lost_mobile.png and b/wechat-minigame/visual/cctv/10_signal_lost_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/11_camera_glitch_mobile.png b/wechat-minigame/visual/cctv/11_camera_glitch_mobile.png
index 5cab460..4dbdb56 100644
Binary files a/wechat-minigame/visual/cctv/11_camera_glitch_mobile.png and b/wechat-minigame/visual/cctv/11_camera_glitch_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/12_scan_active_mobile.png b/wechat-minigame/visual/cctv/12_scan_active_mobile.png
index e5ef20f..549a744 100644
Binary files a/wechat-minigame/visual/cctv/12_scan_active_mobile.png and b/wechat-minigame/visual/cctv/12_scan_active_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/13_entity_near_mobile.png b/wechat-minigame/visual/cctv/13_entity_near_mobile.png
index 3160859..9a37c79 100644
Binary files a/wechat-minigame/visual/cctv/13_entity_near_mobile.png and b/wechat-minigame/visual/cctv/13_entity_near_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/14_shadow_inside_mobile.png b/wechat-minigame/visual/cctv/14_shadow_inside_mobile.png
index 3bc6688..5227a41 100644
Binary files a/wechat-minigame/visual/cctv/14_shadow_inside_mobile.png and b/wechat-minigame/visual/cctv/14_shadow_inside_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/15_anomaly_wandering_mobile.png b/wechat-minigame/visual/cctv/15_anomaly_wandering_mobile.png
index c3a71e6..11e510c 100644
Binary files a/wechat-minigame/visual/cctv/15_anomaly_wandering_mobile.png and b/wechat-minigame/visual/cctv/15_anomaly_wandering_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/16_wrong_floor_mobile.png b/wechat-minigame/visual/cctv/16_wrong_floor_mobile.png
index 9ada129..2cbd358 100644
Binary files a/wechat-minigame/visual/cctv/16_wrong_floor_mobile.png and b/wechat-minigame/visual/cctv/16_wrong_floor_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/17_loop_corridor_mobile.png b/wechat-minigame/visual/cctv/17_loop_corridor_mobile.png
index a8ef9a8..2a6c629 100644
Binary files a/wechat-minigame/visual/cctv/17_loop_corridor_mobile.png and b/wechat-minigame/visual/cctv/17_loop_corridor_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/18_locked_mobile.png b/wechat-minigame/visual/cctv/18_locked_mobile.png
index 2e198ea..0acf7ec 100644
Binary files a/wechat-minigame/visual/cctv/18_locked_mobile.png and b/wechat-minigame/visual/cctv/18_locked_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/19_stabilized_mobile.png b/wechat-minigame/visual/cctv/19_stabilized_mobile.png
index 1838075..4de999f 100644
Binary files a/wechat-minigame/visual/cctv/19_stabilized_mobile.png and b/wechat-minigame/visual/cctv/19_stabilized_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/20_threat_high_mobile.png b/wechat-minigame/visual/cctv/20_threat_high_mobile.png
index 6922832..e11711f 100644
Binary files a/wechat-minigame/visual/cctv/20_threat_high_mobile.png and b/wechat-minigame/visual/cctv/20_threat_high_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/21_maintenance_mode_mobile.png b/wechat-minigame/visual/cctv/21_maintenance_mode_mobile.png
index 25e2eb6..5f57cfb 100644
Binary files a/wechat-minigame/visual/cctv/21_maintenance_mode_mobile.png and b/wechat-minigame/visual/cctv/21_maintenance_mode_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/22_system_reboot_mobile.png b/wechat-minigame/visual/cctv/22_system_reboot_mobile.png
index 95d180f..e0db7c7 100644
Binary files a/wechat-minigame/visual/cctv/22_system_reboot_mobile.png and b/wechat-minigame/visual/cctv/22_system_reboot_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/23_cooldown_safe_mobile.png b/wechat-minigame/visual/cctv/23_cooldown_safe_mobile.png
index 28595ef..f2d4091 100644
Binary files a/wechat-minigame/visual/cctv/23_cooldown_safe_mobile.png and b/wechat-minigame/visual/cctv/23_cooldown_safe_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_00_protocol_start_mobile.png b/wechat-minigame/visual/cctv/v5_00_protocol_start_mobile.png
new file mode 100644
index 0000000..1b3286a
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_00_protocol_start_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_01_quick_mobile.png b/wechat-minigame/visual/cctv/v5_01_quick_mobile.png
new file mode 100644
index 0000000..6e2a3db
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_01_quick_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_02_investigation_mobile.png b/wechat-minigame/visual/cctv/v5_02_investigation_mobile.png
new file mode 100644
index 0000000..94169fc
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_02_investigation_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_03_identity_mobile.png b/wechat-minigame/visual/cctv/v5_03_identity_mobile.png
new file mode 100644
index 0000000..9c6f292
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_03_identity_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_04_classification_mobile.png b/wechat-minigame/visual/cctv/v5_04_classification_mobile.png
new file mode 100644
index 0000000..ff75122
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_04_classification_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_05_high_risk_mobile.png b/wechat-minigame/visual/cctv/v5_05_high_risk_mobile.png
new file mode 100644
index 0000000..5e5f81a
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_05_high_risk_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_06_protocol_query_mobile.png b/wechat-minigame/visual/cctv/v5_06_protocol_query_mobile.png
new file mode 100644
index 0000000..3b82c62
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_06_protocol_query_mobile.png differ
diff --git a/wechat-minigame/visual/cctv/v5_07_debrief_mobile.png b/wechat-minigame/visual/cctv/v5_07_debrief_mobile.png
new file mode 100644
index 0000000..5af6443
Binary files /dev/null and b/wechat-minigame/visual/cctv/v5_07_debrief_mobile.png differ