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 - - - -
-
-
-

MINIGAME · ANOMALY SYSTEM SIM

-

异常电梯控制台

-
-
- 值守倒计时 - 60 -
-
- -
-
-
电梯状态
-
-
F
楼层
1
-
门状态
关闭
-
方向
待机
-
乘客
1
-
电源
100
-
稳定度
100
-
!
异常等级
0
-
广告复活
0
-
加密解码
0
-
待解码
0
-
-
- -
-
监控画面
-
- -

监控画面稳定:1 层轿厢内有 1 名乘客。

-
-
- SIGNAL: STABLE - STANDBY: 首个异常 8s 内出现;盯住 CCTV。 - THREAT: 0 -
-
-
- -
-
操作面板
-
-
- -
-
-
- 先看 CCTV - 画面异常优先于楼层读数。 -
-
- 按推荐键 - 黄色高亮是当前建议动作。 -
-
- -
- -
-
系统日志
-
    -
    -
    - -
    -
    -

    OPERATOR HANDOFF REQUIRED

    -

    等待接管异常电梯

    -

    60 秒守住轿厢。看监控,按键处置。

    -
    - 60sCCTVNO FAIL -
    -
    - POWERSTABILITYANOMALY -
    - - -
    -
    - - - - - - - - - - -
    - - - - 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 @@ - - - - - - 异常电梯控制台 · 基础图标组件 - - - - 关门 - Door Close - - - - - 上行 - Move Up - - - - - - 急停 - Emergency Stop - - - - - - 扫描 - Scan - - - - - 查看日志 - Logs - - - - - 更多操作 - More - - - - 推荐操作 - - thin glow outline / no neon flood - - - - 危险确认 - - long press / slide unlock - - \ 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 @@ -doorupstopscanlogmore \ 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