版本 1.0.370 开发日志

构建 1.0.370(code 370)· 2026-10-02 13:10:37 · sha256 d8f1b00c2230ea10…

下载:公网 · Tailscale · 局域网

开发日志(本轮摘要)

# 贵霜帝国 · 内网开发版 · 本轮开发记录

生成时间:2026-10-02 13:05:46 | 本包:kushan-1.0.370.apk(1.0.370 / code 370,sha256 d8f1b00c2230ea10…)

## 最近开发任务证据(evidence/*/readme.md,新→旧,取 10 份)

### TASK-GUARD-DELETE-TOLERANT  (2026-10-02 12:55:47)

# TASK-GUARD-DELETE-TOLERANT(2026-10-02)——「参数键存在性」新护栏 + 5 个护栏的删件容忍

## 一、补上真实盲区:代码引用的参数键,没人静态扫(→ 新护栏 `tools/verify_param_keys_missing.py`)

**起因(真机逮到)**:1.0.366/367 真机日志有 `ERROR: [Params] 缺参(键不在任何层,也不在出厂 schema): net.quest_timeout_sec`。
`verify_param_wiring` 验的是**运行时行为**「缺参即报错」(H 段),扫不到「代码引用了不存在的键」——
那条代码路径只要测试没跑到,缺参就一直潜伏(本例潜伏到玩家上报差事才可能挂住请求,因为 `HTTPRequest.timeout = 0` 表示**永不超时**)。

**判据(与 `plugins/params.gd` 同源)**:参数存在 ⇔ `data/params.json` 的 `params` 袋里有这个键
(`_raw()` 里 `_effective()` 找不到层 + `meta()` 找不到 schema ⇒ push_error 并返回 0);
世界/帝国/领地/皮/个人各层都是**运行期覆盖**,不是存在性来源。

**做法**:扫 `scripts/`+`plugins/` 全部 `.gd` 里 `Params.<getter|meta|writer>("字面键")`(动态拼接跳过并计数),
与 `data/params.json` 求差。带理由白名单 `tools/param_keys_missing_whitelist.tsv`(键<tab>理由);
`params.json` 读不到 ⇒ `RESULT=SKIP`(不假红)。已转正为 `verify_` 前缀 ⇒ 自动进 `tools/run_verify_all.sh`。

**首跑就抓到 8 个真缺键**(全在 `scripts/voice_input.gd`):`assistant.mic.{sample_rate,channels,max_ms,min_ms,auto_send,enabled,asr_path,asr_timeout_sec}`。
查证=**改名没跟到底**:真名族是 `voice.*`(`voice.sample_rate=16000`/`voice.channels=1`/`voice.max_sec=8`/`voice.enabled=1`/`voice.endpoint=/api/asr`…),
而该文件读的是旧前缀 `assistant.mic.*` ⇒ 读到的全是 0/false/"":**采样率 0、声道 0、录音时长 0、`_mic_btn.visible=false`**(麦克风按钮根本不显示)。

**处置=删件而不是补参**:`scripts/voice_input.gd` 是语音重构前的遗留件 ——
① 全仓**无人实例化它**(只有两处注释提到名字,加 `tools/verify_ui_e21.gd` 的待核清单);
② 它按**旧 4 参**签名调 `MicBridge.bind(sample_rate, channels, max_ms, min_ms)`,而现核心 `scripts/core/mic_bridge.gd` 已是
**6 参** `bind(sample_rate, channels, max_sec, silence_sec, silence_rms, poll_sec)` ⇒ 就算被实例化也会炸;
③ 现役实现是 `scripts/room/voice_bar.gd`(文件头就写着「可调项全在参数 `voice.*`/`tts.*`」)。
⇒ 删 `scripts/voice_input.gd` + `.uid`,并同步 `tools/verify_ui_e21.gd` 的待核清单。删后扫描器 **缺键 0**(扫描键 388→376)。

## 二、删件把 5 个护栏打崩/打红 ⇒ 补「删件容忍」(护栏扩展·技术债)

删掉遗留件后全量回归报 **5 红**,且**不是行号漂移**、是真崩溃(`FileNotFoundError` traceback)/计数失真:
`verify_color`(C.5 站点 + C.6 改前↔改后逐处对账的计数)、`verify_client_i18n`(C1.3 与 C1.7 两处 cache 读件)、
`verify_semantic`(S.2 迁移点计数 + S.5 `_meta.sites` 站点)、`verify_wiring`(W.1 期望调用点)、`verify_fx`(F.5 站点)。

**处置口径**(沿用本仓既有纪律「护栏取不到数据必须报错或跳过,**不能拿空表比绿**」):**跳过 + 出声计数**。
逐处改为「文件不存在 ⇒ 计入 `已删件跳过 N` 并 continue」,行里不留判红、也不静默。
**没有剪基线**:先试过剪 `evidence/TASK-E19/E20/E25/*.tsv` 与 `data/ui_config.json` 的 `_meta.sites`,
副作用是 **21 个键的站点列表被剪空**(会在别的判据上产生新的假红),且让「改前现场」基线失真 ⇒ 全部回滚,改走护栏容忍。
(另记一条坑:往 TSV 里插 `#` 说明行会让只跳空行/表头行的护栏 `int(f[1])` 崩 `IndexError` ⇒ 基线文件保持纯数据,说明写在本文档。)

**复验**(每项单跑绿 + 出声报跳过数):

### TASK-ROOM-POPUP-SHELL  (2026-10-02 12:34:57)

# TASK-ROOM-POPUP-SHELL —「战后房间整块点不动」根因与修复(2026-10-02 · 1.0.367 真机复验通过)

## 现象(真机)
自动战斗打完 → 点「**收 手**」→ **房间整块点不动**:轨迹球(逻辑 `(660,1893.75)`=物理 `(880,2525)`)
的 `input tap` **已被游戏收到**(`KUSHAN_TOUCH` 有记录)却毫无反应;方向键同样无反应、也不换房。
界面看着像正常房间(10-02 起房间卡片 `card_bg_alpha=0`,空弹窗壳**看不见**)⇒ 极易误判为「卡死/注入失效」。

## 定性链(三步,都是实测/读码,不是猜)
1. **命中探针**(cfg `HIT_X=660 HIT_Y=1893`,`room_refresh` 扫描 + `KUSHAN_HIT`):
   - 开战前:命中 2 层,**最上层=`/root/Main/Room_5/Fab (Button)`** ⇒ 轨迹球可点 ✓
   - 开战后:命中 **4 层**,**最上层=`/root/Main/Room_5/@Control@136/@MarginContainer@138/@PanelContainer@139`(全屏 rect=(0,0))** ⇒ 有东西压在轨迹球之上。
2. **读码定位**:战斗面板不是独立场景,而是**挂在房间弹窗的内容区**里
   (`room/room_battle.open_battle_view()` 把 `BattleView` 节点 `add_child` 进 `_interact_box`);
   而 `battle_view._on_close()` 只做 `queue_free()` **关自己、没关弹窗**。
3. **吃点击的是弹窗壳**:`room_popup` 的 `_popup_wrap` 是**全屏 Control**,只要 `_popup.visible`
   就吃掉房间所有点击;空壳+透明卡片让人看不出还开着。

## 修法(两处,均在 `scripts/room/room_popup.gd`)
1. **内容判定不看总数**(2026-10-02):`child_exiting_tree` 是「**即将**离开」时发出(那一刻孩子还在树上),
   而关窗走 `UI.clear_children()`(内部 `queue_free()`=延后释放)⇒ 旧写 `get_child_count() > 0`
   会把「已排队删除」的也算进去 ⇒ 遮罩永久停在 `STOP`。改为 `_has_content_now()`:
   **只数真正在树上、且没被 `is_queued_for_deletion()` 的** ⇒ 与信号/释放时序彻底解耦。
2. **内容空了连壳一起关**:`if not has_content and _popup.visible: _close_popup()`
   (延后一帧判定;正常流程「先 `_clear_interact()` 再同步加内容」同帧仍有内容,不会误关)。

## 真机复验(1.0.367 · 三星 R3CR704Q72V · sha256 `bf6036f9f47b43ea…`)
```
KUSHAN_BATTLE_QA debug_begin ok=true → round=1..4 ok=true → status=win → DONE rounds=4
(点「收 手」)
③ 关键判定:轨迹球能否开出菜单(修前:点了没反应)
  ✅ RESULT=PASS 菜单自报出现(修成立)
  KUSHAN_MENUITEM … 文=🧭 旅伴 / 🎒 背包 / 📜 任务 / 🗺 世界地图 / 🛒 商店 / ⚔ 遭遇战 / 💾 保存进度 / 设置 / 🚪 退出游戏
  KUSHAN_MENUITEM … 文=生命 51/50 | 克什 300.00 …(玩家信息卡也回来了)
④ 真点 [背包] logical=(520,631) phys=(693,841) → 背包面板照常打开(水囊 x1/干馕 x2/使用/丢弃)
```

## 顺带逮到并补登:缺参 `net.quest_timeout_sec`
真机日志里一行 `E godot: ERROR: [Params] 缺参(键不在任何层,也不在出厂 schema): net.quest_timeout_sec`。
查证:`scripts/game_state.gd:220` 在读它,而 `data/params.json` **没有这个键** ⇒ 运行时只报错并拿到 **0**
(`HTTPRequest.timeout = 0` = **永不超时**),差事上报请求可能一直挂着。已按同族风格补登

### TASK-BATTLE-DEVICE  (2026-10-02 12:02:25)

# TASK-BATTLE-DEVICE — 战斗系统真机全链取证(2026-10-02)

**设备/包**:三星 `R3CR704Q72V` · 1.0.365 · cfg `BATTLE_AUTOPLAY=1`(驱动 `scripts/battle_autoplay.gd`)
走**真实玩家路径**:进世界 → 找本屋可交手的人 → `_open_battle_view` 开战 → 推回合。

## 一、结论:战斗可打,服务端裁决到结算全链通 ✅
**附身钱包** `KS194fb06ed5f92ddeaf0bf33f7989356771ba4cae`(`character_panel` 里唯一附身关系:→ 人物 19):
```
KUSHAN_BATTLE_QA begin
KUSHAN_BATTLE_QA target id=17 name=丫鬟阿娜
KUSHAN_BATTLE_QA battle_open
KUSHAN_BATTLE_QA debug_begin ok=true                       ← 开战成立(服务端建 encounter)
KUSHAN_BATTLE_QA round=1 ok=true status=active cmd_count=2
KUSHAN_BATTLE_QA round=2 ok=true status=active cmd_count=2
KUSHAN_BATTLE_QA round=3 ok=true status=active cmd_count=4
KUSHAN_BATTLE_QA round=4 ok=true status=win  cmd_count=3   ← 打赢、服务端结算
KUSHAN_BATTLE_QA DONE rounds=4
```
面板自报(`MENU_DUMP=1`):`攻 击/防 御/逃 走/收 手`+提示「选一个动作,开打。」
截图 OCR(`tools/qa_shot_ocr.py`):结算横幅「**你击败了丫鬟阿娜**」+「收 手」。
⇒ B-013「战斗可打」有真机硬证(本轮)。

## 二、开战的前提:钱包必须**附身**某个人物(领域模型)
同一驱动、同一台机,换**未附身**钱包(`local_trade_qa`/`KSbda5…`)时:
```
KUSHAN_BATTLE_QA debug_begin ok=false
KUSHAN_BATTLE_QA round=1 ok=false status= cmd_count=0
KUSHAN_BATTLE_QA DONE rounds=1
```
服务端原话(直打 `POST /api/combat/start`):
```
{"success":false,"error":"该钱包没有附身任何 NPC,无法战斗"}
```
**根因**:`gateway.py::combat_start` 用 `SELECT cp.character_id FROM character_panel cp WHERE cp.wallet_player=%s` 取 attacker,
未命中即拒。**库内实测只有 1 条附身关系**(`character_panel` id=1:character 19 / `wallet_player=KS194fb06…` / `wallet_npc=KB19xxd_npc`)。
**不是缺陷** —— 是领域模型「玩家一律附身 NPC」的直接体现;但**驱动侧原来只打 `ok=false` 不打原因**,会让排查白跑(本轮已补,见下)。