merge: 代码质量修复批落籍 master(worktree code-quality-fix-20261002,九提交:T1 P0旗纹理槽/T2 探针急救/T3 呈现层结构/T5 core结构/T6 探针基建/T4 UiKit收口/T7 死代码清扫/T8 06登记/并入04edd55)——冲烟636/0+run_all 15套+八探针+熵增PASS+MC对拍200局逐位一致;ui_click_probe budget耗尽=master既有归T17在飞批(纯master对照1:1)

This commit is contained in:
DKChan committed 2026-10-02 10:43:27 +08:00
commit 5debf6ac8e
97 files changed
+1927 -1852

No files matched your search

+42
View File
@@ -523,3 +523,45 @@
### 设置与本地化
## 代码质量修复批降级与后续卡(2026-10-02 修复批登记)
#### 二次确认武装态公共控件(ArmedConfirm)
**现状**:三处武装确认语义异构(AP 条 chips+4s tween 自动回落 / 选路页放弃钮 3s _process 倒计时+危险红呼吸+减动效静态变体 / 战斗总结结算框纯载荷暂存无计时无视觉)——保语义收口需可插拔视觉+双计时机制+可选脉冲,共享件本体 40-60 行而每处仅省约 6 行,且动 press_confirm_for_probe/confirm_visible 探针语义,修复批 T4 降级不做。来源:代码质量审计(ap_bar_view/level_map_screen/main 三份手抄)
**归属**:表现层打磨批(M-4 窗口)
**状态**:待办(2026-10-02 T4 降级登记,做了半套不如不做)
#### main.gd 组合根二期瘦身(1237 → ~900)
**现状**:T3 已迁排播泵/aux 两步交互/GP 影子账三件;到 ~900 还差三选一与替换子交互编排(约 115 行)、debug 模式接线、营地/商店投影 handler 等家族。来源:T3 执行报告
**归属**:下一表现层结构批
**状态**:待办(2026-10-02 登记,ADR-0001 接线留组合根前提不变)
#### aux 替换子交互容器操作二期合并
**现状**:通关渠道(carry 序号寻址+hp 账本收尾)与节点渠道(库存 handle 寻址+node 收尾)已合共用前置谓词,丢弃/授予/收尾三步仍分轨——强合需适配层,收益低。来源:T5 执行报告保守注记
**归属**:随同调位实施批(attunement)顺路评估
**状态**:待办
#### 探针白盒私有域访问改 *_probe() 只读出口
**现状**:ui_input_probe 等直入 BoardView._cells/_diggable/_layout 等私有域(重构即碎),fx_probe 已有只读出口先例。来源:代码质量审计(登记债务,修复批边界外)
**归属**:探针基建批
**状态**:待办
#### 杂项尾差(一批打包)
adventure_table.tres 补 bonus_candidate_count 字段落档(T5 注:脚本默认 3=现行值,落档属结构改动);grant_aux_item 返回值接留痕同型件 level_runner :438 区(T5 注);README.md:402 提及已删 _autoplay_battle_perfect 旧名(T6 注);critical-paths.md:6「切片 96/96」过期字样(T7 注);hero_info_panel_view:29 散文提及已删 PANEL_HERO 色名(T7 注)。来源:T4-T7 执行报告遗留观察
**归属**:下一清扫批顺路
**状态**:待办
+3 -1
View File
@@ -33,6 +33,8 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_probe.t
Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_tune_probe.tscn
```
其余七场景探针(九探针全 roster 登记——ui_click_probe 行为链 / ui_input_probe 输入合成 / ui_layout_probe 布局 / shot_probe·fx_probe·fx_shot_probe·board_shot_probe 画面与特效采样,同上式运行)+ 板图校准工具 `smoke/calibrate_boards.gd`(board_calibration.csv 重跑入口,--script 运行):
全冒险期望扫描(零加成三关连探批量——通关率/止步分布/败退节点;调参期难度读数一律走全冒险口径):
```
@@ -79,7 +81,7 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s
- `B1BattleOrchestrator`:begin_battle 门序列增 `b4.notify_battle_start()`(编排唯一性不变)。
- 表/护栏:RewardTable `POOL_TAGS` + `active_inject`;RewardConfigTable + `active_p0/active_decay`、k_dmg 1→0(F5 休眠);ConfigDB 跨表护栏更新(clear_no_value 含即时类 / no_damage_row 扫描全池活行 / 新增 inject_pool 护栏:非可选类拒绝 + 空池致命)。
**实测(N=200 板/深度,工具 reward_expectation.gd)**:注入 1.00 件/板(j=0,多件板 0)· 占比 小刀 59–65% / 精工 25–29% / 治疗包 7–17%(名义 60/30/10)· 格池零可选类泄漏 ✓。**实际获取期望**(level_monte_carlo「可选类/战」列,N=15):基线 0.88 → +200% 攻击 0.68 件/战(低于分配保证 1.0 的机理 = 注入格均匀随机,落在残余格/速杀未挖区时该件不获取;战斗末无兜底补发——**是否加"战斗末 j==0 补发 1 件"待用户拍板**)。每关合计 ≈ 4.5–6.5 件(原 24.8)✓ 拍板目标达成。
**实测(N=200 板/深度,工具 reward_expectation.gd)**:注入 1.00 件/板(j=0,多件板 0)· 占比 小刀 59–65% / 精工 25–29% / 治疗包 7–17%(名义 60/30/10)· 格池零可选类泄漏 ✓。**实际获取期望**(level_monte_carlo「可选类/战」列,N=15——工具已退役,数据归档):基线 0.88 → +200% 攻击 0.68 件/战(低于分配保证 1.0 的机理 = 注入格均匀随机,落在残余格/速杀未挖区时该件不获取;战斗末无兜底补发——**是否加"战斗末 j==0 补发 1 件"待用户拍板**)。每关合计 ≈ 4.5–6.5 件(原 24.8)✓ 拍板目标达成。
**⚠️ 供给侧副作用(本轮扫描发现,待拍板)**:可选类退出格子池后,其 25% 概率质量被动流向即时类——攻防 buff 每格频率从 44% 升至 59%,基线通关率 55%→80%(N=15)、整关承伤 127→80:**难度不降反升方向反了**。即时类是"每格必有货"锁定设计下无法清空的(除非引入空白格 = 违反锁定事实)。候选杠杆:① 接受更快的 buff 节奏,敌 HP 曲线上调对冲(C3 主杠杆)② 下调 attack_up/defense_up 在即时池内的相对权重(压 buff 频率)③ buff 幅度下调(触碰 B3 表值)。
+67 -62
View File
@@ -53,8 +53,8 @@ signal round_started(round_index: int)
signal board_changed()
signal wallet_changed(event: String, gp: int, extra: Dictionary)
var db
var rng
var db: ConfigDb
var rng: RngServiceCore
var state := "idle" # idle / running / settling(冒险结构.md 三态)
var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享)
var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 统一冒险加算区(商店购买与三选一属性件同一本账;四轴 +ap——迭代 10 奖励重构)
@@ -107,7 +107,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
keystone = 0
summary = {}
_reset_bonus_state()
levels_total = db.level.rows.size()
levels_total = db.level_count() # 表访问收口(2026-10-02——行集直扫退役)
level_index = 1
_pending_next_level = false
_base_override = _normalize_override(params.get("hero_override", {}))
@@ -148,7 +148,7 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
purchased = (bp.get("shop_purchased", {}) as Dictionary).duplicate(true)
keystone = int(bp.get("keystone", 0))
summary = {}
levels_total = db.level.rows.size()
levels_total = db.level_count() # 表访问收口(2026-10-02——行集直扫退役)
level_index = int(bp.get("level_index", 1))
_pending_next_level = false
_reset_bonus_state()
@@ -230,10 +230,10 @@ func runner() -> LevelRunner:
return _runner
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶;与 LevelRunner.rest_heal_amount
# 同式,同一公式两个落点是临时态,注释指认,统一归并归后续批)
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = LevelRunner.rest_heal
#(2026-10-02 修复批——同式双落点字面量退役,与 LevelRunner.rest_heal_amount 同一单点)
func apply_rest(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
return LevelRunner.rest_heal(pct, hp, max_hp)
# 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;三选一未兑付拒绝推进)
@@ -264,6 +264,19 @@ func pick_bonus(id: String) -> bool:
return false
var value: int = int(db.adventure.bonus_value(id))
var hp_before := int(ledger.get("hero_hp", 0))
if id == "aux_item":
return _pick_aux_item(value) # 道具件分派通关渠道(持有容器 = 跨关携带——与节点渠道分轨)
if not _apply_bonus_pick(id, value):
push_error("[冒险结构] pick_bonus 拒绝——未知件: %s" % id)
return false
_finish_bonus_pick(id, value, hp_before)
return true
# 三选一件兑付共享体(2026-10-02 修复批——通关/奖励节点双渠道 match 双胞胎收口):
# 属性件写加算区 + 件获取计数、AP 件写第四轴、GP 件走钱包。道具件不入此体——两渠道持有容器
# 语义不同(跨关携带 vs 关卡域库存),分派回调用方渠道处理;未知件返回 false 由调用方按渠道名报错。
func _apply_bonus_pick(id: String, value: int) -> bool:
match id:
"attack":
bonus["atk"] = int(bonus.get("atk", 0)) + value
@@ -280,37 +293,24 @@ func pick_bonus(id: String) -> bool:
"gp":
if _runner != null:
_runner.grant_bonus_gp(value)
"aux_item":
return _pick_aux_item(value)
_:
push_error("[冒险结构] pick_bonus 拒绝——未知件: %s" % id)
return false
_finish_bonus_pick(id, value, hp_before)
return true
# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_aux_replace)。
# 掷定前置(追加批二):具体道具随候选抽定(_last_draw_aux / _bonus_pending.aux_detail)——本函数消费预掷值,
# 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。
# 持有容器 = 跨关携带 _bonus_aux_carry(与节点渠道的关卡域库存分轨——容器差异保守不合,注记在案)。
func _pick_aux_item(count: int) -> bool:
var pool: Array = _bonus_aux_pool()
if pool.is_empty():
var picked: Array = _draw_pending_aux(_bonus_pending, count, true)
if picked.is_empty():
push_error("[冒险结构] pick_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var hp_before := int(ledger.get("hero_hp", 0))
var picked: Array[String] = []
var detail: Array = (_bonus_pending.get("aux_detail", []) as Array).duplicate()
if detail.is_empty():
detail = _last_draw_aux.duplicate()
for i in maxi(1, count):
if i < detail.size():
picked.append(String(detail[i]))
else:
picked.append(_roll_bonus_aux_item(pool))
var slots := maxi(0, int(db.adventure.transitional_sync_slots))
if _bonus_aux_carry.size() + picked.size() <= slots:
if AuxItems.can_accept(picked.size(), _bonus_aux_carry.size(), db): # 同调位守卫单点(2026-10-02)
for iid in picked:
_bonus_aux_carry.append(iid)
_bonus_aux_carry.append(String(iid))
_finish_bonus_pick("aux_item", picked.size(), hp_before)
return true
# 满位替换子交互:现有持有件 + 新件 N+1 选 N(丢旧收新 / 放弃新件两路,终态恒 ≤ 同调位)
@@ -321,8 +321,9 @@ func _pick_aux_item(count: int) -> bool:
# 替换子交互受理(discard_index = -1 放弃新件不获取;否则丢该件腾位收新)——终态恒 ≤ 同调位
# 持有容器 = 跨关携带(序号寻址);与节点渠道(库存句柄寻址)的丢弃/授予/收尾三步保守不合。
func resolve_aux_replace(discard_index: int) -> bool:
if _bonus_pending.is_empty() or String(_bonus_pending.get("chosen", "")) != "aux_item":
if not _replace_in_transit(_bonus_pending):
push_error("[冒险结构] resolve_aux_replace 拒绝——无替换在途")
return false
var incoming: Array = _bonus_pending.get("incoming", []) as Array
@@ -377,55 +378,33 @@ func apply_node_bonus(id: String) -> bool:
push_error("[冒险结构] apply_node_bonus 拒绝——件不在候选: %s" % id)
return false
var value: int = int(db.adventure.bonus_value(id))
match id:
"attack":
bonus["atk"] = int(bonus.get("atk", 0)) + value
_bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1
"max_hp":
bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value
_bonus_taken["max_hp"] = int(_bonus_taken.get("max_hp", 0)) + 1
"armor":
bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value
_bonus_taken["armor"] = int(_bonus_taken.get("armor", 0)) + 1
"ap":
bonus["ap"] = int(bonus.get("ap", 0)) + value
_bonus_taken["ap"] = int(_bonus_taken.get("ap", 0)) + 1
"gp":
if _runner != null:
_runner.grant_bonus_gp(value)
"aux_item":
return _apply_node_aux(value)
_:
push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id)
return false
if id == "aux_item":
return _apply_node_aux(value) # 道具件分派节点渠道(持有容器 = 关卡域库存——与通关渠道分轨)
if not _apply_bonus_pick(id, value):
push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id)
return false
_finish_node_bonus(id, value)
return true
# 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用);
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨)。
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨,容器语义保守不合)。
# 掷定前置:消费 _node_bonus_pending.aux_detail 预掷值(缺省回退当场掷定)。
func _apply_node_aux(count: int) -> bool:
var pool: Array = _bonus_aux_pool()
if pool.is_empty():
var picked: Array = _draw_pending_aux(_node_bonus_pending, count, false)
if picked.is_empty():
push_error("[冒险结构] apply_node_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
return false
var picked: Array[String] = []
var detail: Array = (_node_bonus_pending.get("aux_detail", []) as Array).duplicate()
for i in maxi(1, count):
if i < detail.size():
picked.append(String(detail[i]))
else:
picked.append(_roll_bonus_aux_item(pool))
var slots := maxi(0, int(db.adventure.transitional_sync_slots))
var records: Array = []
if _runner != null and _runner.aux_inventory != null:
records = _runner.aux_inventory.holding_records()
var holding := records.size() + _bonus_aux_carry.size()
if holding + picked.size() <= slots:
if AuxItems.can_accept(picked.size(), holding, db): # 同调位守卫单点(2026-10-02)
if _runner != null:
for iid in picked:
_runner.grant_aux_item(iid, "reward_node")
var rec: Dictionary = _runner.grant_aux_item(String(iid), "reward_node")
if rec.is_empty():
push_warning("[冒险结构] 奖励节点道具授予被拒(防御兜底留痕): %s" % String(iid))
_finish_node_bonus("aux_item", picked.size())
return true
_node_bonus_pending["chosen"] = "aux_item"
@@ -435,7 +414,7 @@ func _apply_node_aux(count: int) -> bool:
func _resolve_node_replace(discard_handle: int) -> bool:
if _node_bonus_pending.is_empty() or String(_node_bonus_pending.get("chosen", "")) != "aux_item":
if not _replace_in_transit(_node_bonus_pending):
push_error("[冒险结构] resolve_node_replace 拒绝——无替换在途")
return false
var incoming: Array = _node_bonus_pending.get("incoming", []) as Array
@@ -450,6 +429,32 @@ func _resolve_node_replace(discard_handle: int) -> bool:
return true
# 替换在途守卫(2026-10-02 修复批——双渠道替换受理的共用前置谓词:停点态非空且 chosen=aux_item)
func _replace_in_transit(pending: Dictionary) -> bool:
return not pending.is_empty() and String(pending.get("chosen", "")) == "aux_item"
# 双渠道道具件掷定共享体(2026-10-02 修复批——_pick_aux_item/_apply_node_aux 复制债收口一步):
# 解锁道具池空集守卫 + N 件掷定(先消费停点预掷 aux_detail,不足部分当场经 reward_pick 封闭流补掷——
# 取数序与拆分前逐位一致)。返回空数组 = 池空集(调用方按渠道名报错)。
# 保守不合处:通关渠道允许回退 _last_draw_aux(allow_detail_fallback)、节点渠道不回退——掷定回退
# 语义渠道各异,参数化保留差异;持有容器与兑付收尾仍归调用方渠道。
func _draw_pending_aux(pending: Dictionary, count: int, allow_detail_fallback: bool) -> Array:
var pool: Array = _bonus_aux_pool()
if pool.is_empty():
return []
var picked: Array = []
var detail: Array = (pending.get("aux_detail", []) as Array).duplicate()
if detail.is_empty() and allow_detail_fallback:
detail = _last_draw_aux.duplicate()
for i in maxi(1, count):
if i < detail.size():
picked.append(String(detail[i]))
else:
picked.append(_roll_bonus_aux_item(pool))
return picked
# 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号)
func resolve_bonus_replace(discard: int) -> bool:
if not _node_bonus_pending.is_empty() and _node_bonus_pending.has("incoming"):
@@ -488,7 +493,7 @@ func _draw_bonus_candidates() -> Array:
keyed.append({"id": id, "key": pow(rg.randf(), 1.0 / w)})
keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"]))
var out: Array = []
for i in mini(3, keyed.size()):
for i in mini(int(db.adventure.bonus_candidate_count), keyed.size()): # 候选数读表(2026-10-02 魔法数结构化——默认 3 = 现行行为)
var cid := String(keyed[i]["id"])
out.append(cid)
if cid == "aux_item":
+15 -3
View File
@@ -73,6 +73,15 @@ func holding_count() -> int:
return n
# 同调位守卫谓词(2026-10-02 修复批——四处手抄收口单点:战斗结算授予路由 / 关卡授予 / 三选一双渠道):
# holding = 调用方渠道的当前实际持有数(库存渠道传 holding_count()、通关渠道传跨关携带数、
# 节点渠道传库存+携带合计);读数源 = db.adventure.transitional_sync_slots(负值钳 0 口径同前)。
# 返回再入 n 件是否仍在位内。挂 static:谓词纯算术不依赖实例态——通关渠道(跨关携带)没有
# AuxItems 实例可用,实例方法形态不成立;db 参数不标类型(同 _db 桩表注入纪律)。
static func can_accept(n: int, holding: int, db) -> bool:
return holding + n <= maxi(0, int(db.adventure.transitional_sync_slots))
# 持有件记录(迭代 10 追加批——奖励节点替换子交互数据源:只列未消耗件,handle 定位丢弃目标)
func holding_records() -> Array:
var out: Array = []
@@ -105,10 +114,13 @@ func _usable_now(item_id: String, board_has_preview_info: Callable) -> bool:
func _item_row(item_id: String) -> Dictionary:
if _db != null and _db.aux_items != null:
return _db.aux_items.row_by_id(item_id)
return {}
if _db == null:
return {}
if _db.has_method("aux_item_row"):
return _db.aux_item_row(item_id) # 表访问收口(2026-10-02——正式面走 ConfigDB 访问器)
return _db.aux_items.row_by_id(item_id) # 桩表面(单测 StubDB 无访问器——直读保留,随测试面演进退役)
# 不标具体类型原因:单元测试注入 StubDB 桩表(aux_items_test.gd)——typed 赋值会运行期拒收,保守保持 null 注
var _db = null # ConfigDB(行定义读取——_init 注入;测试可注入桩表)
+9 -16
View File
@@ -50,8 +50,8 @@ var action_economy: ActionEconomy
var _enemy_system: UnitContainer
var _status_effects: StatusContainer
var _reward_economy: RewardEconomy
var aux_inventory = null # AuxItems(迭代 7 T3——LevelRunner 注入的关卡域库存引用;
# 每战新建本类,库存不归本类所有——注入面)
var aux_inventory: AuxItems # 迭代 7 T3——LevelRunner 注入的关卡域库存引用;
# 每战新建本类,库存不归本类所有——注入面
var _damage: DamageController # 伤害控制器(伤害管线单据化批 2026-09-24——单据唯一兑付管线)
var encounter: Dictionary[String, Variant] = {}
@@ -126,7 +126,6 @@ func begin_battle(enc: Dictionary) -> bool:
_hero_atk, int(enc.get("hero_max_hp", -1)), rolled_atk):
_fatal("敌人系统 spawn 被拒")
return false
var erow: Dictionary = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth))
if not _install_new_board():
return false
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(resource 恒 false 的双保险)
@@ -375,7 +374,8 @@ func _grant_aux(snap: Dictionary, polarity: String, source_class: String, cell:
# 同调位持有上限(迭代 10 奖励重构批过渡承载——transitional_sync_slots,provisional 1):
# 战斗内渠道遇满位沉没不入(战斗域零交互纪律——替换交互归停点渠道;正主 = 同调位谓词表,
# 全渠道统一收口随魔法道具同调实施批)。逐行归因纪律:沉没行照发(「获得」的零兑付形态)。
if aux_inventory.holding_count() + 1 > maxi(0, int(db.adventure.transitional_sync_slots)):
# 谓词单点 = AuxItems.can_accept(2026-10-02 修复批——四处手抄收口)。
if not AuxItems.can_accept(1, aux_inventory.holding_count(), db):
_emit_row("effect", source_class, cell, ri, "同调位已满,%s 沉没" % String(db.aux_item_row(iid).get("display_name", iid)), 0,
{"effect_id": "", "aux_sink": iid, "sink_reason": "sync_slots_full"}, "")
return false
@@ -396,7 +396,7 @@ func use_aux(handle: int, target: Vector2i) -> Dictionary:
return {"ok": false, "reason": "库存未接线"}
if action_economy.phase != action_economy.PHASE_ACTIVE:
return {"ok": false, "reason": "非挖掘期(使用窗口=Active)"}
var board = _minesweeper_ref()
var board: MinesweeperRules = minesweeper # 恒等包装退役(2026-10-02 修复批——use_aux 直引公有成员)
var v: Dictionary = aux_inventory.validate_use(handle, target,
Vector2(board.width, board.height),
func(c: Vector2i): return not board.is_revealed(c))
@@ -419,10 +419,6 @@ func set_aux_unlocked(ids: Dictionary) -> void:
_reward_economy.set_aux_unlocked(ids)
func _minesweeper_ref() -> Variant:
return minesweeper
func _grant_routed(snap: Dictionary, polarity: String, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
# 迭代 7 T3——aux 投放行授予路由(魔法道具 GDD:授予路由唯一调用方=战斗结算):正路由(挖开/标中)
# → 道具入关卡域库存(来源可归因);负路由(雷格/标错旗摇中 aux 身份——雷安同池)→ 零兑付
@@ -672,18 +668,14 @@ func _combat_segment(ri: int) -> void:
# ---- 算术读数(伤害管线单据化批 2026-09-24 迁移注记)----
# 掷点/折扣/克制点数/池乘区/攻击行文本已全部迁入 伤害控制器(damage_controller.gd 管线单点);
# 本类仅保留两处薄委托——_defender_ac(快照面消费)/ _clamped_pool(冒烟白盒断言直呼面 smoke_all 沿用)。
# 本类仅保留一处薄委托——_defender_ac(快照面消费)。_clamped_pool 委托壳已随 2026-10-02 修复批删除
# (冒烟白盒断言消费面改直引 settlement._damage.clamped_pool——并行卡同批改 smoke_all)。
# 防守方护甲等级读数——实现归 伤害控制器 defender_ac(本委托供结算快照/呈现快照)
func _defender_ac(unit: Dictionary) -> int:
return _damage.defender_ac(unit)
# 池内加算 → clamp——实现归 伤害控制器 clamped_pool(本委托供冒烟白盒断言 smoke_all 直呼兼容)
func _clamped_pool(pool: String) -> int:
return _damage.clamped_pool(pool)
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary = db.status_effect_row(effect_id)
@@ -795,7 +787,8 @@ func _battle_over(result: String) -> void:
_enemy_system.despawn_all()
_status_effects.notify_battle_end()
if aux_inventory != null:
aux_inventory.retire_overlays() # 迭代 7 T3——叠加层随战斗结束清空(库存不动——关卡域跨战斗) action_economy.notify_combat_end() # → Idle,RAP 清零
aux_inventory.retire_overlays() # 迭代 7 T3——叠加层随战斗结束清空(库存不动——关卡域跨战斗)
action_economy.notify_combat_end() # → Idle,RAP 清零(2026-10-02 修复批:原被 tab 事故并进上一行行尾注释从不执行——每战新建实例掩盖了症状;P0-3)
db.set_reload_gate(false)
_reset_round_facts()
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "gp_dropped": gp_dropped})
+8 -6
View File
@@ -6,6 +6,10 @@
class_name BoardData
extends RefCounted
# 出生 3×3 区格数(2026-10-02 修复批 T2 魔法数结构化——W×H−9 布雷域基的双落点收口单点:
# 棋盘生成 _attempt/_finalize 与 BoardGenTable 布雷数下限校验共用;与 spawn_cells() 的 3×3 几何同源)
const SPAWN_AREA_CELLS := 9
var width: int = 0
var height: int = 0
var spawn_center := Vector2i.ZERO
@@ -24,8 +28,10 @@ func in_bounds(c: Vector2i) -> bool:
return c.x >= 0 and c.y >= 0 and c.x < width and c.y < height
func index_of(c: Vector2i) -> int:
return c.y * width + c.x
# 行主序排序键(2026-10-02 修复批——排雷玩法 旗判定批与 地图战斗资源 资格规范化两处手抄收口单点):
# y×1000+x 字典序 = 行主序;板宽域 [8,64] 远小于 1000,x 不跨行、键值不碰撞。
static func row_major_key(c: Vector2i) -> int:
return c.y * 1000 + c.x
func is_mine(c: Vector2i) -> bool:
@@ -44,10 +50,6 @@ func variant_ids(c: Vector2i) -> Array:
return variants.get(c, [])
func has_variant(c: Vector2i, vid: String) -> bool:
return variant_ids(c).has(vid)
func neighbors8(c: Vector2i) -> Array:
var out: Array[Vector2i] = []
for dy in range(-1, 2):
+5 -3
View File
@@ -13,7 +13,8 @@ const METRIC_VERSION := 1
# ==================== 生成入口 ====================
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。
# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表(2026-10-02 注)
func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData:
var row: Dictionary = db.resolve_board_gen(L, D)
if row.is_empty():
@@ -80,6 +81,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
b.level_index = params.L
b.node_depth = params.D
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"
# (−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS——2026-10-02 修复批)
var spawn := {}
for c in b.spawn_cells():
spawn[c] = true
@@ -87,7 +89,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
for c in b.all_cells():
if not spawn.has(c):
domain.append(c)
var n_mines: int = int(round(params.density * float(params.w * params.h - 9)))
var n_mines: int = int(round(params.density * float(params.w * params.h - BoardData.SPAWN_AREA_CELLS)))
# 部分 Fisher-Yates(确定性,消费 board_gen 流)
for i in range(0, mini(n_mines, domain.size())):
var j := rng_stream.randi_range(i, domain.size() - 1)
@@ -314,7 +316,7 @@ func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int:
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, _db) -> void:
var m := measure_inferability(b)
# 产物断言(棋盘生成 出生区安全与反退化布雷):实际雷数 ≤ 目标雷数 N——先布后删只减不增,失败即致命
var n_target: int = int(round(float(params.density) * float(params.w * params.h - 9)))
var n_target: int = int(round(float(params.density) * float(params.w * params.h - BoardData.SPAWN_AREA_CELLS)))
if b.mines.size() > n_target:
_fatal("产物断言违例:实际雷数 %d > 目标雷数 N=%d(先布后删只减不增)" % [b.mines.size(), n_target])
var attempt_facts := b.metrics # 保留尝试期事实(ring_removed)
+31 -204
View File
@@ -48,10 +48,15 @@ var adventure: AdventureTable
var is_ready := false
var _diagnostics: Array[Dictionary] = [] # {severity, category, message}
var _warn_dedup: Dictionary[String, bool] = {} # R1 四元组去重注册表
var _resolver: ConfigDbResolver # R1 难度行解析协作类(2026-10-02 修复批 T5 拆分——门面公有签名零变化)
var _validator := ConfigDbCrossValidator.new() # 跨表校验协作类(无状态——声明期即建)
var _reload_gate := false # 数据配置 EC8——战斗结算 唯一调用方
func _init() -> void:
_resolver = ConfigDbResolver.new(self) # 协作类持门面引用(诊断/致命通道回呼)
func push_diag(severity: String, category: String, message: String) -> void:
_diagnostics.append({"severity": severity, "category": category, "message": message})
if severity == "warning":
@@ -73,7 +78,7 @@ func _fatal(category: String, message: String) -> void:
func reset_diagnostics() -> void:
_diagnostics.clear()
_warn_dedup.clear()
_resolver.reset_warn_dedup() # R1 告警去重注册表(协作类持钥——清空时点与拆分前一致)
func diagnostics() -> Array:
@@ -95,7 +100,7 @@ func _ready() -> void:
func load_tables(root: String, collect_only: bool) -> bool:
is_ready = false
_diagnostics.clear()
_warn_dedup.clear()
_resolver.reset_warn_dedup()
var loaded := {}
for key in TABLE_MANIFEST.keys():
var path := root.path_join(TABLE_MANIFEST[key])
@@ -134,222 +139,31 @@ func load_tables(root: String, collect_only: bool) -> bool:
return not has_errors
# 跨表校验(悬空引用 / 池护栏——全部加载期致命)本体自 2026-10-02 修复批迁出:
# ConfigDbCrossValidator(core/config_db_cross_validator.gd)承载,门面公有签名零变化。
func _cross_validate(loaded: Dictionary) -> void:
var st: StatusEffectTable = loaded.get("status_effects")
var rt: RewardTable = loaded.get("reward")
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or st_tab == null:
return # 单表校验已报错,不连锁
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
# GP+商店房实施批(2026-09-21)例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
for row in rt.rows:
if String(row.get("cell_type", "")) == RewardTable.AUX_ITEM_TYPE:
continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_aux),不入增益词表面
var eff: String = row.get("effect_id", "")
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ①b RewardTable aux 投放行双表校验(迭代 7 T1——reward_dangling_aux):aux 行 effect_id = item_id,
# 须命中 AuxItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
# 后仍投放(连带错误),同拦。
var aux_live := {}
var aux_tab = loaded.get("aux_items")
if aux_tab != null:
for arow in aux_tab.active_rows():
aux_live[String(arow.get("item_id", ""))] = true
for row in rt.rows:
if String(row.get("cell_type", "")) != RewardTable.AUX_ITEM_TYPE:
continue
var aux_eff: String = String(row.get("effect_id", ""))
if aux_eff == "": # 负占位行(雷格极性字典兜底,零兑付)——不入投放词表校验
continue
if not aux_live.has(aux_eff):
push_diag("error", "reward_dangling_aux", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), aux_eff, "双表校验"])
# ①c F2 投放维度覆盖(魔法道具 GDD——五维度各 ≥1 掉落权重为正的活行):豁免面 {plan, info, counter}
# = 罗盘退役(plan,2026-09-29 拍板①乙)+显影件 Alpha 重定(info/counter 悬置)——豁免随 Alpha 重定批消减
# ①c F2 投放维度覆盖校验随战斗掉落渠道退役(2026-10-01 用户拍板甲——挖格不掉道具,魔法道具
# 唯一来路=三选一共享池〔节点/通关〕):旧校验=「掉落池五维度各 ≥1 正权重活行」,载体已随
# reward.tres aux 投放四行删除而消失;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。静态覆盖保证的重立(如按维度钉共享池抽签面)归同调实施批/渠道设计批。
# ② SettlementTable.effect_pool_map 悬空
for eff in st_tab.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, flag_miss}——2026-09-15 格面资源批删 item 键)
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "flag_miss"]
for s in required_sources:
if not st_tab.source_label_map.has(s):
push_diag("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ④ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
# 线性推进器按精确深度行跑,floor 兜底静默降难度属调参事故,加载期直接拦)
var lv: LevelTable = loaded.get("level")
var bg: BoardGenTable = loaded.get("board_gen")
var en: EnemyTable = loaded.get("enemy")
if lv != null and bg != null and en != null:
var bg_keys := {}
for row in bg.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in en.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for lrow in lv.rows:
var li: int = int(lrow.level_index)
# 模板池感知(关卡节点图生成 v0.4——池内全模板跨表校验,不只 row.nodes)
var node_lists: Array[Array] = []
var tpls: Array = lrow.get("templates", [])
if tpls.is_empty():
node_lists.append(lrow.get("nodes", []))
else:
for tpl in tpls:
node_lists.append(tpl.get("nodes", []))
for nodes_arr in node_lists:
for node in nodes_arr:
var t: String = String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var key := "%d_%d" % [li, int(node.get("node_depth", -1))]
if not bg_keys.has(key):
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
if not en_keys.has(key):
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 enemy 无精确行" % [li, t, key])
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
var skt = loaded.get("skill")
if skt != null and en != null:
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
push_diag("error", e.category, e.message)
_validator.cross_validate(loaded, push_diag)
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 +
# proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。
# status_table 显式入参:装载序上本校验先于成员赋值执行(self.status_effects 尚为 Nil)——装载通道传 loaded 表,
# 冒烟直验传 _db.status_effects;传 null 时 side 守卫跳过(其他校验不受影响)。
# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类)
func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
var errs: Array[Dictionary] = []
var skill_types := {}
for row in skill_table.rows:
skill_types[String(row.get("skill_id", ""))] = String(row.get("type", ""))
# 施放类 side 守卫(新效应词汇批 2026-09-19):buff → 敌方侧状态 / debuff → 主角侧状态,防反向挂接
for row in skill_table.rows:
var st: String = String(row.get("type", ""))
if st != "buff" and st != "debuff":
continue
var want_side: String = "enemy" if st == "buff" else "hero"
var eff: String = String(row.get("buff_ref", ""))
var srow: Dictionary = status_table.row_by_id(eff) if status_table != null else {}
if not srow.is_empty() and String(srow.get("target_side", "")) != want_side:
errs.append({"category": "cast_side", "message": "SkillTable %s(%s)引用 %s 的 target_side=%s ≠ %s(施放类 side 守卫)" % [String(row.get("skill_id", "")), st, eff, String(srow.get("target_side", "")), want_side]})
for row in enemy_table.rows:
var eid: String = String(row.get("enemy_id", ""))
var basic_n := 0
var buff_n := 0
var debuff_n := 0
for entry in row.get("pool", []):
var sid: String = String(entry.get("skill_id", ""))
if not skill_types.has(sid):
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
continue
match skill_types[sid]:
"basic":
basic_n += 1
"buff":
buff_n += 1
"debuff":
debuff_n += 1
if basic_n != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
if buff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
if debuff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]})
return errs
return _validator.validate_skill_pool_links(skill_table, enemy_table, status_table)
# ---- R1 难度行解析(2026-09-18 用户拍板修订:ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)----
# 解析本体自 2026-10-02 修复批迁出:ConfigDbResolver(core/config_db_resolver.gd)承载——
# 含 _resolve_progress/_ceil_key 与 R1 告警去重注册表;门面同名方法委托,越域 Fatal 语义逐位一致。
func resolve_board_gen(L: int, D: int) -> Dictionary:
return _resolve_progress(board_gen.rows, L, D, "board_gen")
return _resolver.resolve_board_gen(L, D)
func resolve_enemy_rowset(L: int, D: int) -> Dictionary:
return _resolve_progress(enemy.rows, L, D, "enemy")
return _resolver.resolve_enemy_rowset(L, D)
# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
if difficulty == null:
return {}
for row in difficulty.rows:
if int(row.get("level_index", 0)) == L and int(row.get("node_depth", -1)) == D:
return {
"hp_mult": clampf(float(row.get("hp_mult", 1.0)), 0.1, 10.0),
"atk_mult": clampf(float(row.get("atk_mult", 1.0)), 0.1, 10.0),
}
return {}
func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Dictionary:
if L < 1 or D < 0:
_fatal("request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D])
return {}
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
if level_keys.is_empty():
_fatal("structural", "%s 行集为空——锚行校验应已拦截" % table_id)
return {}
level_keys.sort()
var l_star := _ceil_key(level_keys, L)
var l_clamped := l_star < 0
if l_clamped:
l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(2026-09-18 追加拍板:可取范围内向上取整,超出用最大值)
var depth_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star:
var dv := int(row.get("node_depth", 0))
if not depth_keys.has(dv):
depth_keys.append(dv)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := ""
if l_clamped and d_clamped:
missing = "both_clamp"
elif l_clamped:
missing = "level_clamp"
elif d_clamped:
missing = "depth_clamp"
elif l_star != L and d_star != D:
missing = "both_ceil"
elif l_star != L:
missing = "level_ceil"
elif d_star != D:
missing = "depth_ceil"
if missing != "":
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
push_diag("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
# 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal
func _ceil_key(sorted_keys: Array, request: int) -> int:
for k in sorted_keys:
if int(k) >= request:
return int(k)
return -1
return _resolver.resolve_difficulty_modifiers(L, D)
# ---- 地形变异 variant_pool_gate:候选池门控(∅ board_types = 通配;2026-09-19 用户裁定字段名对齐
@@ -373,6 +187,19 @@ func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# ---- 关卡行集访问器(2026-10-02 修复批 T5 表访问收口——level_runner/meta_progression/adventure_runner
# 直扫 db.level.rows 退役;消费面一律走本访问器,行集解析单点归 LevelTable)----
# 关卡行单点读取(键 = level_index,缺行返回空字典——消费方按既有空行防御路径处理)
func level_row(level_index: int) -> Dictionary:
return level.row_by_level_index(level_index) if level != null else {}
# 序列总关数(AdventureRunner levels_total / MetaProgression 通关累加域的取数口径)
func level_count() -> int:
return level.rows.size() if level != null else 0
# ---- GP+商店房实施批(2026-09-21):ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)----
# ── 魔法道具访问器(迭代 7 T1——消费面一律走访问器,禁直读行集)──
# 活行集(非 retired 非 deferred)——投放校验/奖励节点池/呈现可用性的唯一取数口径
+146
View File
@@ -0,0 +1,146 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-10-02(code-quality 修复批 T5——ConfigDb 拆分:跨表校验协作类,自 config_db.gd 迁出)
# 职责单一化(Rule 6 校验双通道的跨表一侧):_cross_validate 全域(悬空引用/池护栏/节点精确行/
# 技能池构成)+ validate_skill_pool_links(冒烟直验独立入口)。ConfigDb autoload 门面保持——
# load_tables 调用点与冒烟直验经门面同名方法,公有签名零变化。
# 诊断通道注入:跨表校验的行级错误经 push_diag: Callable 外发(门面传 Callable(self, "push_diag"))——
# 本类零 ConfigDb 依赖(无状态纯校验器,装载序上先于成员赋值执行的前提保持)。
# 迁移注记:validate_skill_pool_links 的 status_table 显式入参纪律照抄(装载通道传 loaded 表,
# 冒烟直验传门面 status_effects;null 时 side 守卫跳过)——返回错误列表不进诊断通道,消费面归调用方。
# 实现 GDD:design/archive/gdd-2026-09-14/d2-data-config.md(系统 ID D2·数据配置——registry 追溯锚)
class_name ConfigDbCrossValidator
extends RefCounted
func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
var st: StatusEffectTable = loaded.get("status_effects")
var rt: RewardTable = loaded.get("reward")
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or st_tab == null:
return # 单表校验已报错,不连锁
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
# GP+商店房实施批(2026-09-21)例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
for row in rt.rows:
if String(row.get("cell_type", "")) == RewardTable.AUX_ITEM_TYPE:
continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_aux),不入增益词表面
var eff: String = row.get("effect_id", "")
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
push_diag.call("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ①b RewardTable aux 投放行双表校验(迭代 7 T1——reward_dangling_aux):aux 行 effect_id = item_id,
# 须命中 AuxItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
# 后仍投放(连带错误),同拦。
var aux_live := {}
var aux_tab = loaded.get("aux_items")
if aux_tab != null:
for arow in aux_tab.active_rows():
aux_live[String(arow.get("item_id", ""))] = true
for row in rt.rows:
if String(row.get("cell_type", "")) != RewardTable.AUX_ITEM_TYPE:
continue
var aux_eff: String = String(row.get("effect_id", ""))
if aux_eff == "": # 负占位行(雷格极性字典兜底,零兑付)——不入投放词表校验
continue
if not aux_live.has(aux_eff):
push_diag.call("error", "reward_dangling_aux", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), aux_eff, "双表校验"])
# ①c F2 投放维度覆盖(魔法道具 GDD——五维度各 ≥1 掉落权重为正的活行):豁免面 {plan, info, counter}
# = 罗盘退役(plan,2026-09-29 拍板①乙)+显影件 Alpha 重定(info/counter 悬置)——豁免随 Alpha 重定批消减
# ①c F2 投放维度覆盖校验随战斗掉落渠道退役(2026-10-01 用户拍板甲——挖格不掉道具,魔法道具
# 唯一来路=三选一共享池〔节点/通关〕):旧校验=「掉落池五维度各 ≥1 正权重活行」,载体已随
# reward.tres aux 投放四行删除而消失;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。静态覆盖保证的重立(如按维度钉共享池抽签面)归同调实施批/渠道设计批。
# ② SettlementTable.effect_pool_map 悬空
for eff in st_tab.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag.call("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, flag_miss}——2026-09-15 格面资源批删 item 键)
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "flag_miss"]
for s in required_sources:
if not st_tab.source_label_map.has(s):
push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ④ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
# 线性推进器按精确深度行跑,floor 兜底静默降难度属调参事故,加载期直接拦)
var lv: LevelTable = loaded.get("level")
var bg: BoardGenTable = loaded.get("board_gen")
var en: EnemyTable = loaded.get("enemy")
if lv != null and bg != null and en != null:
var bg_keys := {}
for row in bg.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in en.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for lrow in lv.rows:
var li: int = int(lrow.level_index)
# 模板池感知(关卡节点图生成 v0.4——池内全模板跨表校验,不只 row.nodes)
var node_lists: Array[Array] = []
var tpls: Array = lrow.get("templates", [])
if tpls.is_empty():
node_lists.append(lrow.get("nodes", []))
else:
for tpl in tpls:
node_lists.append(tpl.get("nodes", []))
for nodes_arr in node_lists:
for node in nodes_arr:
var t: String = String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var key := "%d_%d" % [li, int(node.get("node_depth", -1))]
if not bg_keys.has(key):
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
if not en_keys.has(key):
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%s) 在 enemy 无精确行" % [li, t, key])
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
var skt = loaded.get("skill")
if skt != null and en != null:
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
push_diag.call("error", e.category, e.message)
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 +
# proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。
# status_table 显式入参:装载序上本校验先于成员赋值执行(self.status_effects 尚为 Nil)——装载通道传 loaded 表,
# 冒烟直验传 _db.status_effects;传 null 时 side 守卫跳过(其他校验不受影响)。
func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
var errs: Array[Dictionary] = []
var skill_types := {}
for row in skill_table.rows:
skill_types[String(row.get("skill_id", ""))] = String(row.get("type", ""))
# 施放类 side 守卫(新效应词汇批 2026-09-19):buff → 敌方侧状态 / debuff → 主角侧状态,防反向挂接
for row in skill_table.rows:
var st: String = String(row.get("type", ""))
if st != "buff" and st != "debuff":
continue
var want_side: String = "enemy" if st == "buff" else "hero"
var eff: String = String(row.get("buff_ref", ""))
var srow: Dictionary = status_table.row_by_id(eff) if status_table != null else {}
if not srow.is_empty() and String(srow.get("target_side", "")) != want_side:
errs.append({"category": "cast_side", "message": "SkillTable %s(%s)引用 %s 的 target_side=%s ≠ %s(施放类 side 守卫)" % [String(row.get("skill_id", "")), st, eff, String(srow.get("target_side", "")), want_side]})
for row in enemy_table.rows:
var eid: String = String(row.get("enemy_id", ""))
var basic_n := 0
var buff_n := 0
var debuff_n := 0
for entry in row.get("pool", []):
var sid: String = String(entry.get("skill_id", ""))
if not skill_types.has(sid):
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
continue
match skill_types[sid]:
"basic":
basic_n += 1
"buff":
buff_n += 1
"debuff":
debuff_n += 1
if basic_n != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
if buff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
if debuff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]})
return errs
@@ -0,0 +1 @@
uid://d4dfucylnemk6
+108
View File
@@ -0,0 +1,108 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-10-02(code-quality 修复批 T5——ConfigDb 拆分:R1 难度行解析协作类,自 config_db.gd 迁出)
# 职责单一化(R1 解析域):resolve_board_gen / resolve_enemy_rowset / resolve_difficulty_modifiers
# 及其私有算术(_resolve_progress / _ceil_key)与 R1 专属的告警去重注册表。ConfigDb autoload 门面
# 保持——全部消费方(战斗结算/敌人系统/棋盘生成/呈现层/冒烟)经门面同名方法取数,公有签名零变化。
# 诊断通道归门面:push_diag / _fatal 经 _db 引用回呼(协作类同仓内部通道——不另设公共出口,
# 行为与拆分前逐位一致,含越域 Fatal 退出语义)。
# 迁移注记:_warn_dedup 去重注册表随解析逻辑同迁(唯一消费方 = _resolve_progress)——
# 门面 load_tables / reset_diagnostics 经 reset_warn_dedup() 同步清空,清空时点零变化。
# 实现 GDD:design/archive/gdd-2026-09-14/d2-data-config.md(系统 ID D2·数据配置——registry 追溯锚)
class_name ConfigDbResolver
extends RefCounted
var _db: ConfigDb # 门面引用(表读取 + 诊断/致命通道——_init 注入)
var _warn_dedup: Dictionary[String, bool] = {} # R1 四元组去重注册表(自 ConfigDb 迁入)
func _init(db_ref: ConfigDb) -> void:
_db = db_ref
# 去重注册表清空(门面 load_tables / reset_diagnostics 调用——与拆分前清空时点逐位一致)
func reset_warn_dedup() -> void:
_warn_dedup.clear()
# ---- R1 难度行解析(2026-09-18 用户拍板修订:ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)----
func resolve_board_gen(L: int, D: int) -> Dictionary:
return _resolve_progress(_db.board_gen.rows, L, D, "board_gen")
func resolve_enemy_rowset(L: int, D: int) -> Dictionary:
return _resolve_progress(_db.enemy.rows, L, D, "enemy")
# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
if _db.difficulty == null:
return {}
for row in _db.difficulty.rows:
if int(row.get("level_index", 0)) == L and int(row.get("node_depth", -1)) == D:
return {
"hp_mult": clampf(float(row.get("hp_mult", 1.0)), 0.1, 10.0),
"atk_mult": clampf(float(row.get("atk_mult", 1.0)), 0.1, 10.0),
}
return {}
func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Dictionary:
if L < 1 or D < 0:
_db._fatal("request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D])
return {}
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
if level_keys.is_empty():
_db._fatal("structural", "%s 行集为空——锚行校验应已拦截" % table_id)
return {}
level_keys.sort()
var l_star := _ceil_key(level_keys, L)
var l_clamped := l_star < 0
if l_clamped:
l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(2026-09-18 追加拍板:可取范围内向上取整,超出用最大值)
var depth_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star:
var dv := int(row.get("node_depth", 0))
if not depth_keys.has(dv):
depth_keys.append(dv)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := ""
if l_clamped and d_clamped:
missing = "both_clamp"
elif l_clamped:
missing = "level_clamp"
elif d_clamped:
missing = "depth_clamp"
elif l_star != L and d_star != D:
missing = "both_ceil"
elif l_star != L:
missing = "level_ceil"
elif d_star != D:
missing = "depth_ceil"
if missing != "":
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
_db.push_diag("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
# 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal
func _ceil_key(sorted_keys: Array, request: int) -> int:
for k in sorted_keys:
if int(k) >= request:
return int(k)
return -1
+1
View File
@@ -0,0 +1 @@
uid://bv7guty0wowk
+3 -3
View File
@@ -49,9 +49,9 @@ const TARGET_HERO := "hero"
const TARGET_ALL_ALIVE := "all_alive_enemies"
var db: ConfigDb
var rng
var units # UnitContainer——目标解析/伤害交付/存活查询(句柄即 Dictionary 引用)
var effects # StatusContainer——池值/AC 型效果读取(增益状态 只读消费面)
var rng: RngServiceCore # 注入成员类型注(2026-10-02 修复批——单测经真实 RngService 构造,标注安全)
var units: UnitContainer # UnitContainer——目标解析/伤害交付/存活查询(句柄即 Dictionary 引用)
var effects: StatusContainer # StatusContainer——池值/AC 型效果读取(增益状态 只读消费面)
var emit_row_cb: Callable # 行协议注入口:func(type, source_class, origin_cell, ri, display, value, extra, highlight)
var policy := POLICY_SEGMENT # 结账时机开关(2026-09-24 用户拍板第二步拨 B=攒批段界统一结;回切 A 改回 POLICY_IMMEDIATE 即还原迁移前行为)
+45 -34
View File
@@ -61,16 +61,19 @@ var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
var _settlement = null # 当前战斗编排器(关卡结束置 null)
var aux_inventory = null # 魔法道具 关卡域库存(迭代 7 T3——AuxItems;跨战斗延续,
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
var aux_inventory: AuxItems = null # 魔法道具 关卡域库存(迭代 7 T3——AuxItems;跨战斗延续,
var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——拍板③甲;空集=零投放)
# 关卡末清空+快照计数入 level_finished 载荷)
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
var _running := false
var level_result := "" # "" / "victory" / "defeat"
var _awaiting_choice := false # 选路态(兑付确认后、pick 前为零自动推进的可见停点)
# ---- 关卡相位轴(2026-10-02 修复批——布尔旗收拢:_running/_awaiting_choice 两旗 → 显式 Phase enum;
# 对齐 行动经济 Phase 先例(action_economy.gd:内部 enum + 守卫换算,外部读面契约零变化)——
# level_result:String 与 awaiting_choice() 被 AdventureRunner/呈现层消费的语义逐字不变)----
enum Phase { IDLE, RUNNING, AWAITING_CHOICE, ENDED }
var _phase: int = Phase.IDLE
var level_result := "" # "" / "victory" / "defeat"(外部契约面——语义零变化)
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
@@ -81,6 +84,11 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
rng = rng_ref
# 进行中谓词(原 _running 语义——RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用)
func _is_running() -> bool:
return _phase == Phase.RUNNING or _phase == Phase.AWAITING_CHOICE
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 encounter 传入战斗)----
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
@@ -135,7 +143,7 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
return false
node_index = -1
level_result = ""
_awaiting_choice = false
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
@@ -152,11 +160,11 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
hero_hp = effective_max_hp()
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
_running = true
_phase = Phase.RUNNING
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
_running = false
_phase = Phase.ENDED
return false
return _enter_node(entry)
@@ -189,7 +197,7 @@ func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dicti
level_row.assign(_resolve_template_row(row, template_pick))
node_index = -1
level_result = ""
_awaiting_choice = false
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
@@ -197,7 +205,7 @@ func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dicti
if node.has("next"):
_graph_mode = true
break
_running = true
_phase = Phase.RUNNING
# 断点续玩路径(迭代 8 卡①——defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
# 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
@@ -207,10 +215,10 @@ func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dicti
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
_running = false
_phase = Phase.ENDED
return false
if defer_entry:
_awaiting_choice = true # 入口停点(node_index 保持 -1——进节点前无 current_node)
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node)
_candidates = [entry]
return true
return _enter_node(entry)
@@ -219,7 +227,7 @@ func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dicti
# 入口停点呈现(停点前移批 2026-09-23——defer_entry=true 的第二步):发出入口候选供呈现层弹
# 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。
func present_entry_choice() -> bool:
if not _running or not _awaiting_choice or _candidates.is_empty() or node_index != -1:
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index != -1:
push_error("[关卡节点地图] present_entry_choice 拒绝——非入口停点态(defer_entry 未开或已 pick)")
return false
choices_presented.emit(_candidates)
@@ -237,9 +245,9 @@ func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
_running = false
_phase = Phase.ENDED
return false
_awaiting_choice = true
_phase = Phase.AWAITING_CHOICE
_candidates = [entry]
return true
var stop: Dictionary = {}
@@ -249,7 +257,7 @@ func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
break
if stop.is_empty():
push_error("[关卡节点地图] 断点恢复失败——停点节点编号 %d 不在本关图" % node_id)
_running = false
_phase = Phase.ENDED
return false
node_index = nodes().find(stop)
visited = []
@@ -258,11 +266,11 @@ func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
if _node_id(node) == int(vid):
visited.append(node)
break
_awaiting_choice = true
_phase = Phase.AWAITING_CHOICE
_candidates = _out_nodes(stop)
if _candidates.is_empty():
push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id)
_running = false
_phase = Phase.ENDED
return false
return true
@@ -270,7 +278,7 @@ func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
# 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口
# (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。
func present_resumed_choice() -> bool:
if not _running or not _awaiting_choice or _candidates.is_empty() or node_index < 0:
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index < 0:
push_error("[关卡节点地图] present_resumed_choice 拒绝——非中途停点态")
return false
choices_presented.emit(_candidates)
@@ -286,10 +294,7 @@ func restore_aux_items(items: Array) -> void:
func _find_level_row(level_index: int) -> Dictionary:
for row in db.level.rows:
if int(row.level_index) == level_index:
return row
return {}
return db.level_row(level_index) # 表访问收口(2026-10-02——行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 模板池解析(关卡节点图生成 v0.4——M-3 迭代 5 T5)----
@@ -411,11 +416,11 @@ func visited_ids() -> Array:
func awaiting_choice() -> bool:
return _awaiting_choice
return _phase == Phase.AWAITING_CHOICE
func _enter_node(node: Dictionary) -> bool:
if not _running:
if not _is_running():
return false
node_index = nodes().find(node)
visited.append(node)
@@ -490,7 +495,7 @@ func _on_battle_finished(payload: Dictionary) -> void:
var hp_left := hero_hp if result == "victory" else 0
if not _external:
close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle
_running = false
_phase = Phase.ENDED
_settlement = null
var aux_cleared := 0
if aux_inventory != null:
@@ -505,12 +510,17 @@ func _on_battle_finished(payload: Dictionary) -> void:
_present_choices()
# 恢复数学单一路径(漂移③清偿——同一公式两个落点:本体内置默认 + AdventureRunner.apply_rest,
# 统一归并归后续批):floor(上限 × 比例 ÷ 100) 后封顶缺口;冒险结构 GDD 公式逐位一致
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
# 恢复数学单一路径(漂移③清偿;2026-10-02 修复批——同式双落点收口:算术宿主 = 本静态方法,
# AdventureRunner.apply_rest 同式委托,字面量双写退役)。floor(上限 × 比例 ÷ 100) 后封顶缺口;
# 冒险结构 GDD 公式逐位一致
static func rest_heal(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
return rest_heal(pct, hp, max_hp) # 公有成文出口保持(start_level 内置恢复执行体经 Callable 引用)
func _apply_rest() -> int:
var pct: int = int(level_row.get("rest_heal_pct", 0))
# 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest
@@ -528,7 +538,7 @@ func _present_choices() -> bool:
if cands.is_empty():
push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)")
return false
_awaiting_choice = true
_phase = Phase.AWAITING_CHOICE
_candidates = cands
choices_presented.emit(cands)
return true
@@ -536,11 +546,11 @@ func _present_choices() -> bool:
# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生)
func pick_next_node(node_id: int) -> bool:
if not _awaiting_choice:
if _phase != Phase.AWAITING_CHOICE:
return false
for cand in _candidates:
if _node_id(cand) == node_id:
_awaiting_choice = false
_phase = Phase.RUNNING
_candidates = []
return _enter_node(cand)
return false
@@ -563,7 +573,7 @@ func set_aux_unlocked(ids: Dictionary) -> void:
func grant_aux_item(item_id: String, source: String) -> Dictionary:
if aux_inventory == null:
aux_inventory = AuxItems.new(db)
if aux_inventory.holding_count() + 1 > maxi(0, int(db.adventure.transitional_sync_slots)):
if not AuxItems.can_accept(1, aux_inventory.holding_count(), db): # 同调位守卫单点(2026-10-02)
push_warning("[关卡节点地图] 同调位已满,授予拒绝: %s(%s)" % [item_id, source])
return {}
return aux_inventory.add_item(item_id, source, Vector2i(-1, -1), 0)
@@ -642,7 +652,8 @@ func buy_product(product_id: String) -> bool:
return false
var price := current_price(prow)
if gp < price:
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝)
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp])
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝;2026-10-02 补静默留痕)
gp -= price
var flat := int(prow.get("flat_value", 0))
bonus[attr] = int(bonus[attr]) + flat
+6 -1
View File
@@ -74,13 +74,18 @@ func _workshop_progress(channel: String) -> int:
match channel:
"total_clears":
var total := 0
for i in db.level.rows.size():
for i in db.level_count(): # 表访问收口(2026-10-02——行集直扫退役)
total += save.clear_count(i + 1)
return total
_:
return 0 # 未知渠道恒 0 = 永不解锁(fail-safe 非致命——渠道词表校验兜底,不静默误解锁)
# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager)
func is_item_unlocked(item_id: String) -> bool:
return save.item_unlocked(item_id)
# 工坊六行只读投影(呈现层数据源):{item_id, display_name, threshold, unlocked, progress}
func workshop_status() -> Array:
var out: Array[Dictionary] = []
+13 -5
View File
@@ -13,8 +13,6 @@ signal cell_revealed(evt: Dictionary)
signal flag_judged(evt: Dictionary)
signal round_end_board(payload: Dictionary) # 双发射源之棋盘路径(排雷玩法 外发;stopped 由 行动经济 直发)
const SUB_REASONS := ["not_adjacent", "target_revealed", "target_concealed", "flag_present", "no_path", "out_of_catalog"] # flag_absent 随 flag_pull 退役(2026-09-19)
var db: ConfigDb
var action_economy: ActionEconomy
# 棋盘状态私有(E2 封装收口 2026-09-13):对外只暴露 current_board()/reveal_record() 等查询面,
@@ -75,6 +73,15 @@ func clear_map_cleared() -> bool:
return safe_remaining() == 0
# 未开格存在查询(aux 道具 usable 预判消费——全板扫描只读面)
func has_unrevealed() -> bool:
for y in _board.height:
for x in _board.width:
if not _revealed.has(Vector2i(x, y)):
return true
return false
# ---- install(编排边界相位 = 行动经济 Idle/Waiting;Active 拒绝——防轮内换板)----
# during_round = 轮内刷新编排路径(2026-09-12 方案 A,战斗结算 唯一调用方):Active 相位合法——
@@ -107,11 +114,11 @@ func install_board(b: BoardData, during_round := false) -> Dictionary:
return _outcome(true, "executed", "", "")
# during_round = 轮内刷新编排路径(2026-09-12 方案 A):Active 相位合法——换图前退役旧板
func flag_count() -> int:
return _flags.size()
# during_round = 轮内刷新编排路径(2026-09-12 方案 A):Active 相位合法——换图前退役旧板
func retire_board(during_round := false) -> Dictionary:
if _board == null:
return _outcome(true, "executed", "", "") # 幂等受理
@@ -416,9 +423,10 @@ func _do_stop() -> Dictionary:
# ---- 轮末旗判定 + 棋盘路径 round_end 外发 ----
func _emit_flags_batch() -> void:
# 判定基线 = 轮次结束事务开始时旗位;行主序枚举(确定性);被拔的不判;诚实重复上报(去重归 战斗结算)
# 判定基线 = 轮次结束事务开始时旗位;行主序枚举(确定性——排序键单点 BoardData.row_major_key,
# 2026-10-02 修复批);被拔的不判;诚实重复上报(去重归 战斗结算)
var cells: Array = _flags.keys()
cells.sort_custom(func(a, b): return a.y * 1000 + a.x < b.y * 1000 + b.x)
cells.sort_custom(func(a, b): return BoardData.row_major_key(a) < BoardData.row_major_key(b))
for c in cells:
flag_judged.emit({"cell": c, "hit": _board.is_mine(c)})
+2 -20
View File
@@ -41,7 +41,7 @@ func roll_board(eligible: Array) -> bool:
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)")
return false
# 行主序规范化 + 去重(排序责任不外推给 战斗结算)
# 行主序规范化 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key——2026-10-02 修复批)
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
@@ -51,7 +51,7 @@ func roll_board(eligible: Array) -> bool:
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return a.cell.y * 1000 + a.cell.x < b.cell.y * 1000 + b.cell.x)
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
_eligible = list
_granted.clear() # 新批重置映射受理面(旧图快照消亡)
_power_roll.clear()
@@ -172,24 +172,6 @@ func polarity_row(cell_type: String, polarity: String) -> Dictionary:
return {}
# ---- F6 当量供给(行动经济 条款①对照实物输入——全活行投影 + tier_min_buff)----
func equivalence_supply() -> Dictionary:
var live: Array[Dictionary] = []
var tier_min_buff := 99
for row in db.reward.rows:
if int(row.weight) <= 0:
continue
live.append(row.duplicate()) # E6:表行值拷贝出口——消费方禁改配置源
# tier_min_buff = 我侧增益(hero_offense/hero_defense 池的 percent 正向行——attack_up/defense_up)最低档
if String(row.get("pool_tag", "")) == "cell" and String(row.get("polarity", "")) == "positive" \
and String(row.get("effect_id", "")) != "":
var eff_row: Dictionary = db.status_effect_row(String(row.effect_id))
if not eff_row.is_empty() and String(eff_row.get("target_side", "")) == "hero":
tier_min_buff = mini(tier_min_buff, int(row.tier))
return {"live_rows": live, "tier_min_buff": tier_min_buff}
# ---- F1 选行算子纯函数 ----
func weighted_pick(rows: Array, r: int) -> String:
+3 -3
View File
@@ -39,7 +39,7 @@ const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合
var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径)
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;生产链现为空——骨架先行)
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v3 生产迁移链现行——_init 自注册)
func _init(path: String = "user://save.json") -> void:
@@ -155,7 +155,7 @@ func write_to_disk() -> bool:
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移当前为空(v1 首版),
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v3,_init 自注册),
# 冒烟经本口验证链式升级。
func register_migration(from_version: int, fn: Callable) -> void:
_migrations[from_version] = fn
@@ -265,10 +265,10 @@ func _corrupt_fallback(reason: String) -> bool:
# 迁移后/低版本档字段补齐(改版不弃档:缺新域按默认补齐,既有值保留)
func _normalize(data: Dictionary) -> Dictionary[String, Variant]:
var merged := _default_data()
var def_meta: Dictionary = merged["meta"] # 补齐源引用(先于 data 覆盖取出——data 自带 meta 时 merged["meta"] 被换走,此引用仍指默认值)
for key in data:
merged[key] = data[key]
var meta: Dictionary = merged.get("meta", {})
var def_meta: Dictionary = _default_data()["meta"]
for key in def_meta:
if not meta.has(key):
meta[key] = def_meta[key]
-8
View File
@@ -106,14 +106,6 @@ func query_active(side: String) -> Array:
return out
# 池层数读值(战斗结算·战斗段伏击池 L 的消费面)
func pool_layers(effect_id: String) -> int:
for inst in _instances:
if inst.effect_id == effect_id:
return inst.layers
return 0
# 轮末衰减:先兑付后衰减;每实例恰 −1;归 0 移除;返回移除数 k(恰一次执行由 战斗结算 编排信任承载)
func decay_round() -> int:
if not _open:
+2 -1
View File
@@ -62,8 +62,9 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override:
var hp_mult: float = float(mod.get("hp_mult", 1.0))
var atk_mult: float = float(mod.get("atk_mult", 1.0))
# unit_count_override ≥1 = 战斗结算 遭遇骰结果(敌人系统 零随机纪律——收到即确定值;-1 = 行值定值路径)
# 数量域常量单点 = EnemyTable.UNIT_COUNT_MIN/MAX(2026-10-02 修复批——与表校验同源)
var count: int = unit_count_override if unit_count_override >= 1 else int(row.unit_count)
if count < 1 or count > 3:
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 实例化数量越域: %d" % count)
return false
for i in range(count):
+6
View File
@@ -18,6 +18,10 @@ const BONUS_CAP_UNLIMITED := -1
# —— 通关部分回血(固定型,非末关通关兑付)——
@export var clear_heal_pct: int = 20 # [0,100] knob——回复生效上限的百分比,封顶上限不回满
# —— 三选一候选数(2026-10-02 修复批 T2 魔法数结构化:自 adventure_runner 抽签字面量 3 收口进表;
# 默认 3 = 现行行为零改动——加权洗牌不放回掷 N 的 N,改值须重扫三选一体验与牌库剔除节奏)——
@export var bonus_candidate_count: int = 3 # [1,6] knob——候选数上界受 BONUS_IDS 词表封闭约束
# —— 三选一件值(兑付面)——
@export var bonus_attack_flat: int = 1 # [0,999] knob——攻击轴累加(覆盖值通道)
@export var bonus_max_hp_flat: int = 5 # [0,999] knob——生命上限轴累加(不连带当前生命)
@@ -93,6 +97,8 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "bonus_cap(%s) 越域 [0,99]: %d" % [id, c]})
if bonus_aux_item_count < 1 or bonus_aux_item_count > 9:
errs.append({"category": "domain", "message": "bonus_aux_item_count 越域 [1,9]: %d" % bonus_aux_item_count})
if bonus_candidate_count < 1 or bonus_candidate_count > BONUS_IDS.size():
errs.append({"category": "domain", "message": "bonus_candidate_count 越域 [1,%d]: %d" % [BONUS_IDS.size(), bonus_candidate_count]})
if transitional_sync_slots < 0 or transitional_sync_slots > 9:
errs.append({"category": "domain", "message": "transitional_sync_slots 越域 [0,9]: %d" % transitional_sync_slots})
return errs
+2 -2
View File
@@ -51,8 +51,8 @@ func validate() -> Array:
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
# 布雷数下限(R3)——零雷板拒绝
var n_mines := int(round(d * float(w * h - 9)))
# 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS——2026-10-02 修复批)
var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
+9 -4
View File
@@ -15,6 +15,11 @@ extends Resource
# 2026-09-15 命名统一批:位置键 {none, a, b} → 语义键 {light, ward, heavy} = 轻甲 / 魔法护甲 / 重甲)
const ARMOR_TYPES := ["light", "ward", "heavy"]
# 实例化数量域(2026-10-02 修复批 T2 魔法数结构化——数量域 [1,3] 双落点收口单点:
# 本表 validate 与 单位容器 spawn 守卫共用;遭遇骰结果与行值 unit_count/unit_count_max 同域)
const UNIT_COUNT_MIN := 1
const UNIT_COUNT_MAX := 3
func validate() -> Array:
var errs: Array[Dictionary] = []
@@ -39,13 +44,13 @@ func validate() -> Array:
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
var uc: int = int(row.get("unit_count", 0))
if uc < 1 or uc > 3:
errs.append({"category": "domain", "message": "%s unit_count 越域 [1,3]" % eid})
if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX:
errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]})
# unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > 3:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),3]" % [eid, ucm, uc]})
if ucm < uc or ucm > UNIT_COUNT_MAX:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
+8
View File
@@ -20,6 +20,14 @@ extends Resource
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 商店房批 2026-09-21 四→五类(关卡节点地图 v1.1)
# 行单点读取(2026-10-02 修复批 T5 表访问收口——LevelRunner._find_level_row 直扫收口;键 = level_index)
func row_by_level_index(level_index: int) -> Dictionary:
for row in rows:
if int(row.level_index) == level_index:
return row
return {}
func validate() -> Array:
var errs: Array[Dictionary] = []
if rows.is_empty():
+2 -9
View File
@@ -44,7 +44,8 @@ func validate() -> Array:
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var atk_type: String = String(row.get("attack_type", ""))
if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type):
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) # aux 投放行专属校验(迭代 7 T1):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]})
# aux 投放行专属校验(迭代 7 T1):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
# 对 aux_items 词表校验——reward_dangling_aux)/无 power 无区间(数值语义不复用,道具效果读 AuxItemTable)
if ctype == AUX_ITEM_TYPE:
# 负向行 = 雷格极性字典占位(雷安同池摇号下雷格可摇中 aux 身份——负路由零兑付兜底,
@@ -118,14 +119,6 @@ func validate() -> Array:
return errs
func rows_by_pool(pool_tag: String) -> Array:
var out: Array[Dictionary] = []
for row in rows:
if row.get("pool_tag", "") == pool_tag:
out.append(row)
return out
func row_by_id(rid: String) -> Dictionary:
for row in rows:
if row.get("reward_id", "") == rid:
@@ -0,0 +1,137 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 魔法道具两步交互控制器(迭代 7 T4;T3 拆件批 2026-10-02 自 main.gd 迁出——规范 1.1 单一职责):
# C 区槽位选中/取消(toggle)→ 棋盘点格使用目标拾取的两步交互状态机 + 库存/叠加层只读投影装配。
# 数据源 = runner 级库存只读面(query_inventory/query_overlays)+ 表行投影(display_name/target/effect_kinds);
# 写调用唯一经 战斗结算.use_aux(core 侧四步编排——拒绝零消耗 toast 可辨认)。
# 装配纪律:settlement/runner 随关卡逐关更替——经组合根现值回调注入(仿 identity_resolver 只读回调
# 先例);呈现句柄(信息面板/toast/棋盘叠加层)由组合根注入;ADR-0001——信号连接不在此发生,
# info_panel.aux_slot_clicked 由组合根接至 on_slot_clicked。
# 无目标使用显式化(T3 收口):AP 类(target=none)本无坐标可给——原调用面伪造 Vector2i(-9,-9)
# 哨兵坐标;现调用面一律 attempt_use(handle)(cell 缺省=null=显式「无目标」),core 契约的
# Vector2i 形参收口于本类常量(aux_items.validate_use 对 ap 行忽略目标值,哨兵不出本类)。
extends RefCounted
const NO_TARGET := Vector2i(-9, -9) # core use_aux 形参占位(仅 target=none 行到达——validate_use 忽略该值)
var db
var meta # 解锁门控数据源(工坊解锁×活行交集——冒险开始组装;组合根 _ready 建立后注入)
var info_panel: HeroInfoPanelView
var toast: ToastStack # 反馈面(board_zone.toast 注入)
var board_view: BoardView
var _settlement_get: Callable # () → 当前战斗结算现值(组合根 lambda)
var _runner_get: Callable # () → 当前关卡推进器现值(组合根 lambda)
var _use_selection := 0 # 选中句柄(迭代 7 T4——0=未选中;选中期棋盘点击=使用目标拾取)
func _init(db_ref, meta_ref, info_panel_ref: HeroInfoPanelView, toast_ref: ToastStack,
board_view_ref: BoardView, settlement_get: Callable, runner_get: Callable) -> void:
db = db_ref
meta = meta_ref
info_panel = info_panel_ref
toast = toast_ref
board_view = board_view_ref
_settlement_get = settlement_get
_runner_get = runner_get
# aux 解锁门控集组装(迭代 7 T5——拍板③甲:工坊解锁 ∩ 活行词表;罗盘退役/失义件不入集)
func unlocked_set() -> Dictionary:
var out := {}
if meta == null:
return out
for row in db.aux_active_rows():
var iid := String(row.get("item_id", ""))
if meta.is_item_unlocked(iid):
out[iid] = true
return out
# 选中态读数(组合根棋盘点击劫持判据——0=未选中)
func active_selection() -> int:
return _use_selection
# 槽点击(第一步):AP 类(target=none)点击即用(无目标);空间类进/出选中态(toggle 取消)。
func on_slot_clicked(slot_handle: int) -> void:
var settlement = _settlement_get.call()
var runner = _runner_get.call()
if settlement == null or runner == null or runner.aux_inventory == null:
return
if slot_handle == 0:
_use_selection = 0
refresh_slots()
return
var rec: Dictionary = {}
for r in inventory_projection():
if int(r.get("handle", 0)) == slot_handle:
rec = r
break
if rec.is_empty() or bool(rec.get("used", false)):
return
var arow: Dictionary = db.aux_item_row(String(rec.get("item_id", "")))
if String(arow.get("target", "none")) == "none":
attempt_use(slot_handle) # AP 类:显式无目标即用(不再伪造哨兵坐标)
return
if not bool(rec.get("usable", true)):
toast.show_toast("当前无可预读的未开格", "warn")
return
_use_selection = 0 if _use_selection == slot_handle else slot_handle # toggle 取消
refresh_slots()
if _use_selection != 0:
toast.show_toast("%s:点击目标格使用(9 宫窗口),再点道具取消" % String(rec.get("name", "")), "info")
# 使用受理(第二步;cell=空间类目标格,null=无目标;core 侧 use_aux 四步编排——拒绝零消耗 toast 可辨认)
func attempt_use(handle: int, cell: Variant = null) -> void:
var settlement = _settlement_get.call()
if settlement == null:
return
var target: Vector2i = cell if cell != null else NO_TARGET
var v: Dictionary = settlement.use_aux(handle, target)
if not bool(v.ok):
toast.show_toast(String(v.get("reason", "无法使用")), "warn")
return
_use_selection = 0
refresh_slots()
refresh_overlays()
var arow: Dictionary = db.aux_item_row(String(v.get("item_id", "")))
toast.show_toast("已使用 %s" % String(arow.get("display_name", "")), "info")
# 库存投影(C 区四槽取数面——字形=名首字;usable=板级零信息预判回调接排雷玩法只读面)
func inventory_projection() -> Array:
var runner = _runner_get.call()
if runner == null or runner.aux_inventory == null:
return []
var settlement = _settlement_get.call()
var ms = settlement.minesweeper if settlement != null else null
var cb := Callable()
if ms != null:
cb = func(_c: Vector2i) -> bool:
return ms.has_unrevealed() # 只读查询面(排雷玩法 has_unrevealed——手搓全板双循环收口)
var out: Array = []
for rec in runner.aux_inventory.query_inventory(cb):
var arow: Dictionary = db.aux_item_row(String(rec.get("item_id", "")))
var dn := String(arow.get("display_name", ""))
out.append({
"handle": int(rec.get("handle", 0)),
"item_id": String(rec.get("item_id", "")),
"glyph": dn.left(1) if dn != "" else "?",
"name": dn,
"used": bool(rec.get("used", false)),
"usable": bool(rec.get("usable", true)),
})
return out
func refresh_slots() -> void:
info_panel.set_aux_inventory(inventory_projection(), _use_selection)
# 叠加层投影注入(use 成功/三清后——棋盘标记与预读徽章重算)
func refresh_overlays() -> void:
var runner = _runner_get.call()
if runner == null or runner.aux_inventory == null or board_view == null:
return
var ov: Dictionary = runner.aux_inventory.query_overlays()
board_view.set_aux_overlays(ov.get("mine_reveal", []), ov.get("reward_preview", []))
@@ -0,0 +1 @@
uid://cgqtlpkl0ypn0
+8 -8
View File
@@ -6,24 +6,24 @@
extends RefCounted
# 节点进入横幅(棋盘日志——battle/boss 用;节点编号 = 行进器编号空间读面)
# 节点进入横幅(棋盘日志——battle/boss 用;节点编号 = 行进器编号空间读面;行色 = UiKit.HEX_* 语义档单点)
static func node_banner(runner, node: Dictionary, t: String) -> String:
return "[color=#e0b860]── 节点 %d/%d · %s(编号 %d)──[/color]" % [
return "[color=#%s]── 节点 %d/%d · %s(编号 %d)──[/color]" % [UiKit.HEX_LOG_GAIN,
runner.node_index + 1, runner.nodes().size(),
"Boss 房" if t == "boss" else "战斗", runner.node_id_of(node)]
static func reward_line() -> String:
return "[color=#d0c090]关卡奖励点(节点内容待定——直通)[/color]"
return "[color=#%s]关卡奖励点(节点内容待定——直通)[/color]" % UiKit.HEX_ROUTE_NOTE
static func rest_line(healed: int, hp: int, max_hp: int) -> String:
return "[color=#7cc47c]恢复点:回复 %d HP(→ %d/%d)[/color]" % [healed, hp, max_hp]
return "[color=#%s]恢复点:回复 %d HP(→ %d/%d)[/color]" % [UiKit.HEX_LOG_HEAL, healed, hp, max_hp]
# 商店房行(GP+商店房实施批——到达即开店;购买/离开归玩家意图)
static func shop_line(gp: int) -> String:
return "[color=#e0b860]商店房:金币 %d——购买即时生效,离开后选下一跳[/color]" % gp
return "[color=#%s]商店房:金币 %d——购买即时生效,离开后选下一跳[/color]" % [UiKit.HEX_LOG_GAIN, gp]
# 选路口行(零自动推进停点——战斗胜利/兑付确认后出现)
@@ -31,18 +31,18 @@ static func route_line(candidates: Array, runner) -> String:
var names: Array[String] = []
for cand in candidates:
names.append("节点 %d" % runner.node_id_of(cand))
return "[color=#e0b860]── 岔路口:可选下一跳 %s ──[/color]" % " / ".join(names)
return "[color=#%s]── 岔路口:可选下一跳 %s ──[/color]" % [UiKit.HEX_LOG_GAIN, " / ".join(names)]
# 关卡开场行(种子/加成口径注记——面板加成是否生效由调用方传入)
static func level_open_line(level_display: String, seed_val: int, atk: int, max_hp: int, boosted: bool) -> String:
return "[color=#c8a860]%s · 种子 %d · 攻击 %d / 生命 %d%s[/color]" % [
return "[color=#%s]%s · 种子 %d · 攻击 %d / 生命 %d%s[/color]" % [UiKit.HEX_SEED_NOTE,
level_display, seed_val, atk, max_hp, "(含面板加成)" if boosted else ""]
# 调试开场行
static func debug_open_line(seed_val: int, level: int, level_mode: bool) -> String:
return "[color=#c8a860]种子 %d · 遭遇 %d 层%s[/color]" % [
return "[color=#%s]种子 %d · 遭遇 %d 层%s[/color]" % [UiKit.HEX_SEED_NOTE,
seed_val, level, "(关卡模式 · HP 延续)" if level_mode else ""]
-4
View File
@@ -33,10 +33,6 @@ func _init(db_ref, rng_ref, hero_override: Callable, seed_value: Callable) -> vo
_seed_value = seed_value
func is_level_mode() -> bool:
return _level_mode
# 单场遭遇原语(2026-09-13 用户裁决:单场用户入口已删——本方法仅 ui_probe 单场段使用;
# 无延续、standalone 终局语义保留供探针战斗收尾)
func start_single(level: int) -> bool:
+31
View File
@@ -0,0 +1,31 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# GP 冒险钱包呈现侧影子账本(迭代 6 T7 卡⑩;T3 拆件批 2026-10-02 自 main.gd 迁出——规范 1.1):
# 冒险进行中余额影子 + 收束清零拍转存的最终合计,供冒险终局面板 GP 行取数。
# 手工镜像边界:wallet_changed gain/buy 载荷 = 入账后总额(level_runner.gd:482/539),"clear" 到达即
# 影子转存 final 并归零——纯呈现侧账本,零核心触碰(C1 呈现层方案);写入口唯一 = apply_event
#(组合根 _on_wallet_changed 投喂;探针合成注入经 set_view_total——组合根委托面转发)。
extends RefCounted
var _view := 0 # 冒险进行中余额影子(gain/buy 载荷直录)
var _final := 0 # 收束清零拍转存的最终合计("clear" 到达时自影子转存)
func apply_event(event: String, gp: int) -> void:
match event:
"gain", "buy":
_view = gp
"clear":
_final = _view
_view = 0
func set_view_total(v: int) -> void:
_view = v # 探针合成注入面(ui_probe 影子账清零转存断言)
func view_total() -> int:
return _view
func final_total() -> int:
return _final
@@ -0,0 +1 @@
uid://uk77ei6hjhca
+24 -1
View File
@@ -83,6 +83,29 @@ static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db, refresh_remai
ap_bar.set_stop_enabled(bool(s.hero.get("alive", false)) and digging)
# ---- 战斗收尾反馈与结算视觉同步(T3 HUD 单通道批 2026-10-02——组合根直改视图收敛入此) ----
# battle_over 收尾反馈:死亡兜底灰化 → 主角态回 idle → RAP 清零脉冲(调试收尾路)→ 状态全清可见反馈。
# 调试/冒险两路共用;reset_rap=false = 冒险收束(RAP 清零由战斗末拍已毕,收束拍不再重放脉冲)。
static func battle_end_feedback(stage: BattleStageView, ap_bar: ApBarView, reset_rap: bool) -> void:
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
stage.settle_hero_idle() # 主角态回 idle(迭代 6 T3 三态收尾)
if reset_rap:
ap_bar.set_rap(0) # RAP 清零脉冲 + 微文案(载体 2026-09-26 迁 D 区 AP 条)
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体迁 B 区站桩)
# 结算阶段视觉同步(round_end → 下一轮):D 区去饱和 + 禁挖光 + 明细带 settle/dig 换挡 + 轮次文案。
# 光带演出(play_round_sweep)属时序敏感触发,留在组合根(上升沿检测随行)——本条只做态驱动 setter。
static func sync_settlement_visuals(stage: BattleStageView, board_view: BoardView,
log_view: BattleLogView, settling: bool, phase_text: String) -> void:
board_view.set_desaturated(settling)
board_view.set_dig_glow_enabled(not settling)
stage.set_settle_focus(settling)
log_view.set_mode("settle" if settling else "dig")
stage.set_round_text(phase_text)
# 战斗外(节点间隙/关卡结束)——HP 读 runner 携带值
static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView,
ap_bar: ApBarView, runner, level_active: bool, db) -> void:
@@ -128,7 +151,7 @@ static func outcome_text(outcome: Dictionary) -> String:
static func tune_sim_report(n: int, atk_pct: int, hp_pct: int, wins: int, dmg_all: Array, hp_left_win: Array, hp_boss: Array, rounds_all: Array, death_nodes: Dictionary, base_hp: int) -> String:
var lines: Array[String] = []
lines.append("[b]模拟 N=%d(atk+%d%% / hp+%d%%)[/b]" % [n, atk_pct, hp_pct])
lines.append("通关率 [color=#80d080]%.0f%%[/color](%d/%d)" % [100.0 * float(wins) / float(maxi(1, n)), wins, n])
lines.append("通关率 [color=#%s]%.0f%%[/color](%d/%d)" % [UiKit.HEX_LOG_WIN, 100.0 * float(wins) / float(maxi(1, n)), wins, n])
lines.append("整关承伤:均 %.1f · p90 %.0f(基础 HP %d)" % [_mean(dmg_all), _p90(dmg_all), base_hp])
if not hp_left_win.is_empty():
lines.append("通关剩余 HP 均 %.1f · 进 Boss HP 均 %.1f" % [_mean(hp_left_win), _mean(hp_boss)])
+128 -291
View File
@@ -18,7 +18,16 @@
# 渲染与文案归 views/,核心状态→文案装配归 hud_presenter.gd 与 battle_narrator.gd,节点构建归
# ui_assembler.gd,模态层级归 modal_stack.gd,批量模拟编排归 tune_sim_controller.gd,调试模式编排归
# debug_session.gd——本类不拼呈现字符串。
# ADR-0001 纪律:呈现层永不直接订阅 排雷玩法/行动经济——只订 战斗结算/runner 转发信号;相位读取仅用于驱动视图状态。
# T3 拆件批(2026-10-02,规范 1.1 单一职责):结算行排播泵迁 row_pacer.gd(本类只入队/排空/读队列,
# 探针/测试委托面 _row_queue/pace_delay_for/_flush_row_queue 原位保留);魔法道具两步交互迁
# aux_interact_controller.gd(本类只接线与转发意图,无目标使用显式化——哨兵坐标不出控制器);
# GP 冒险钱包影子账迁 gp_shadow_ledger.gd(本类留探针同名读写委托面)。模态开合全量走
# _open_modal/_close_modal 单口,输入锁=modal_stack.has_open() 开态查询+三例外;battle 收尾反馈与
# 结算视觉 setter 收敛 hud_presenter 单通道(时序敏感演出触发留本类并注释);settlement 相位读取改经
# presentation_snapshot 成文面、变异表读取直读本根 db(settlement.db 即同一实例)——禁
# settlement.action_economy / settlement.db 链式穿透。
# ADR-0001 纪律:呈现层永不直接订阅 排雷玩法/行动经济——只订 战斗结算/runner 转发信号;相位读取仅用于
# 驱动视图状态;信号连接只在组合根(row_pacer/aux_interact_controller 零连接,只收注入)。
extends Control
const UiAssembler := preload("res://presentation/ui_assembler.gd")
@@ -26,6 +35,9 @@ const Narrator := preload("res://presentation/battle_narrator.gd")
const ModalStack := preload("res://presentation/modal_stack.gd")
const TuneSimController := preload("res://presentation/tune_sim_controller.gd")
const DebugSessionScript := preload("res://presentation/debug_session.gd")
const RowPacer := preload("res://presentation/row_pacer.gd") # 结算行排播泵(T3 拆件批)
const AuxInteractController := preload("res://presentation/aux_interact_controller.gd") # 魔法道具两步交互(T3 拆件批)
const GpShadowLedgerScript := preload("res://presentation/gp_shadow_ledger.gd") # GP 影子账本(T3 拆件批)
const SaveManagerScript := preload("res://core/save_manager.gd") # 存档承载(preload 避开全局类缓存时序——与 DebugSessionScript 同款)
const MetaProgressionScript := preload("res://core/meta_progression.gd") # 元进度账本服务(元进度实现批——同 preload 纪律)
@@ -67,16 +79,32 @@ var board_zone: BoardZoneView # D 区(含 ap_bar/board_view/toast/
var board_view: BoardView # 探针兼容别名(= board_zone.board_view)
var ap_bar: ApBarView # 探针兼容别名(= board_zone.ap_bar)
var settlement_fx: SettlementFxView # 结算演出覆盖层(跨区弹道/光带——A–D 之上、模态之下)
# T3 拆件批(2026-10-02)——三件迁出后的组合根持有面(职责见各类头注;装配见 _build_ui)
var _row_pacer = null # 结算行排播泵(row_pacer.gd——本类只入队/排空/读队列)
var _aux = null # 魔法道具两步交互(aux_interact_controller.gd——本类只接线与转发意图)
var _gp_ledger := GpShadowLedgerScript.new() # GP 冒险钱包影子账(gp_shadow_ledger.gd——wallet 事件投喂)
# GP 影子账委托面(探针/终局面板取数保留——ui_probe 合成注入经 set 转发账本,读面即账本实况)
var _adventure_gp_view: int:
get:
return _gp_ledger.view_total()
set(value):
_gp_ledger.set_view_total(value)
var _adventure_gp_final: int:
get:
return _gp_ledger.final_total()
# 排播队列只读委托面(探针/测试读 main._row_queue——RowPacer 迁移后保留的同名可读面,共享同一 Array 引用)
var _row_queue: Array:
get:
return _row_pacer.pending_rows()
# 模态 overlay(z 序 = 登记序;pattern 6 层级纪律)
var legend_overlay: LegendOverlay
var review_overlay: SettlementReviewOverlay
var node_overlay: NodeOverlay
var shop_overlay: ShopOverlay # 商店覆盖层(GP+商店房实施批——商店房节点兑付呈现)
var _adventure_gp_view := 0 # GP 冒险钱包呈现侧影子(迭代 6 T7——终局面板 GP 行取数面,
# wallet_changed gain/buy 载荷=入账后总额 level_runner.gd:482/539)
var _adventure_gp_final := 0 # 收束清零拍暂存的最终合计("clear" 到达时自影子转存)
var _aux_use_selection := 0 # 魔法道具选中句柄(迭代 7 T4——0=未选中;选中期棋盘点击=使用目标拾取)
var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——工坊解锁×活行交集,冒险开始组装)
var camp_overlay: CampOverlay # 营地覆盖层(元进度实现批——主菜单营地入口兑付呈现)
var settings_store # 系统级设置独立存储(迭代 8 卡④——音量/全屏/减动效)
var settings_overlay: SettingsOverlay # 设置覆盖层(迭代 8 卡④——最小设置面)
@@ -89,27 +117,18 @@ var _refresh_used_round := 0 # 本轮已受理刷新次数(遥测 round_r
var _boss_active := false # Boss 遭遇变体(阶段指示附 Boss 名)
var _was_settling := false # 结算相位上升沿检测(结算轮光带触发——演出链批)
# ---- 结算行排播队列(迭代 8 卡⑦——演出层排播:逻辑先行零回压,行按段分组阶梯延迟进明细带)----
# 硬契约:核心层零 await 不破——本队列只做信号的"后置消费",结算顺序/结果/时序逻辑零改动(§3 契约 1);
# 减动效开=延迟归零走同帧直落快路(行呈现不跳过——§3 契约 5);模态开启前强制排空(明细回看/停点
# 覆盖层所见明细带恒完整)。
var _row_queue: Array = [] # 待排播行(step_row 载荷原样入队;__round_line 标记=轮首分隔行)
var _row_pace_wait := 0.0 # 距下一行排播的剩余秒(_process 消耗)
var _pace_prev_segment := "" # 上一排播行的段型(段间/段内延迟判据)
var _was_pumping := false # 排播活跃边沿(T17——末行消费帧触发结算视觉回挖掘态刷新)
var _locked_click := {} # 锁期点击缓存(T17 v3.1——最后一次;演播完边沿重放,玩家操作零丢失)
# 锁期点击重放(v3.1):延迟一帧重入唯一动作通道——此刻队列空锁已解,按普通动作受理
#(局面在演播期无变化:逻辑先行、演播只是回放——点击语义保真)。
#(局面在演播期无变化:逻辑先行、演播只是回放——点击语义保真;_row_queue=RowPacer 委托面,
# 排播泵本体已迁 row_pacer.gd——T3 结构段)。
func _replay_locked_click() -> void:
if _locked_click.is_empty() or not _row_queue.is_empty():
return
var click: Dictionary = _locked_click
_locked_click = {}
_on_board_action(click["cell"], click["kind"])
const PACE_SEGMENT_GAP := 0.3 # 段间阶梯延迟(代拍板:白名单 0.2–0.4s 内取值,追认清单呈报)
const PACE_ROW_GAP := 0.22 # 段内间隔(三轮 #3 上调 0.06→0.22——逐条演出可感知,配合数字浮现/状态脉冲)
func _ready() -> void:
@@ -152,9 +171,25 @@ func _build_ui() -> void:
board_view = battle_ui.board_view # 探针兼容别名(= board_zone.board_view)
ap_bar = battle_ui.ap_bar # 探针兼容别名(= board_zone.ap_bar)
settlement_fx = battle_ui.settlement_fx
# 结算行排播泵(T3 拆件批——迁 row_pacer.gd;本类注入视图句柄与呈现刷新回调,零信号连接)
_row_pacer = RowPacer.new()
_row_pacer.log_view = log_view
_row_pacer.stage = stage
_row_pacer.board_zone = board_zone
_row_pacer.settlement_fx = settlement_fx
_row_pacer.board_view = board_view
_row_pacer.refresh_request = _refresh_presentations
_row_pacer.pump_ended = _replay_locked_click # T17 v3.1——演播完边沿重放锁期点击(合并批接线)
add_child(_row_pacer)
# 魔法道具两步交互(T3 拆件批——迁 aux_interact_controller.gd;settlement/runner 随关卡更替,
# 经现值回调注入——仿 identity_resolver 只读回调先例)
_aux = AuxInteractController.new(db, meta, info_panel, board_zone.toast, board_view,
func() -> Variant: return settlement, func() -> Variant: return runner)
log_view.review_requested.connect(_open_review_from_band)
board_zone.action_requested.connect(_on_board_action)
info_panel.aux_slot_clicked.connect(_on_aux_slot_clicked) # 迭代 7 T4——两步交互第一步
info_panel.aux_slot_clicked.connect(_aux.on_slot_clicked) # 迭代 7 T4——两步交互第一步
board_zone.stop_pressed.connect(_on_stop_pressed)
board_zone.refresh_confirmed.connect(_on_refresh_confirmed)
board_zone.legend_toggled.connect(_on_legend_toggled)
@@ -170,7 +205,8 @@ func _build_ui() -> void:
logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 选路 < 终局 < 商店 < 调整板 < 开始)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局
# < 商店 < 营地 < 调整板 < 设置 < 确认弹窗)
var overlays: Dictionary = UiAssembler.build_overlays(self, db, int(db.hero.atk), int(db.hero.max_hp))
legend_overlay = overlays.legend_overlay
review_overlay = overlays.review_overlay
@@ -201,10 +237,6 @@ func _build_ui() -> void:
settings_overlay = SettingsOverlay.new(settings_store) # 迭代 8 卡④——最小设置面(音量/全屏/减动效)
add_child(settings_overlay)
settings_overlay.closed.connect(_on_settings_closed)
confirm_dialog = ConfirmDialog.new() # 二轮修改批 A——新冒险互斥确认弹窗
add_child(confirm_dialog)
confirm_dialog.confirmed.connect(_on_confirm_new_adventure)
confirm_dialog.cancelled.connect(_on_confirm_cancelled)
# 提升三选一覆盖层(迭代 10——z 序 = 节点层之上、终局层之下;确认后同拍推进)
bonus_overlay = BonusPickOverlay.new()
add_child(bonus_overlay)
@@ -212,10 +244,6 @@ func _build_ui() -> void:
bonus_overlay.replace_chosen.connect(_on_bonus_replace_chosen)
bonus_overlay.continue_pressed.connect(_on_bonus_continue)
move_child(bonus_overlay, _end_overlay.get_index())
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, bonus_overlay, level_screen, _end_overlay, shop_overlay, camp_overlay, tune_panel, settings_overlay, confirm_dialog]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
var start: Dictionary = UiAssembler.build_start_overlay(self, db)
_start_overlay = start.overlay
@@ -238,10 +266,23 @@ func _build_ui() -> void:
var diag_btn := UiAssembler.build_diag_button(self)
diag_btn.pressed.connect(_on_diagnose_pressed)
# 确认弹窗(二轮修改批 A——新冒险互斥确认弹窗化):树序落在开始层/过场层/诊断钮之后 = 子级最末
#(T3 收口批——「登记序 = 树序 = z 序」纪律复位,confirm_dialog.open 的 move_child 运行时自提
# 创可贴随之删除;弹窗只在开始菜单域弹出,绘制序恒最上)
confirm_dialog = ConfirmDialog.new()
add_child(confirm_dialog)
confirm_dialog.confirmed.connect(_on_confirm_new_adventure)
confirm_dialog.cancelled.connect(_on_confirm_cancelled)
# 模态登记(缺陷⑤纪律——登记序 = 树序:含 move_child 调位后的关卡画面/三选一终位)
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, level_screen, bonus_overlay, _end_overlay, shop_overlay, camp_overlay, tune_panel, settings_overlay, confirm_dialog]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
func _fatal_screen(msg: String) -> void:
var l := UiKit.label(msg, 20, true)
l.add_theme_color_override("font_color", UiKit.RED_ALERT)
var l := UiKit.label(msg, 20, true, UiKit.RED_ALERT)
l.set_anchors_preset(Control.PRESET_CENTER)
add_child(l)
@@ -271,10 +312,9 @@ func _on_modal_closed(m: Control) -> void:
func _input_locked() -> bool:
# 模态/非战斗期棋盘输入锁(各 overlay 自身可见即锁定)
return legend_overlay.is_open() or review_overlay.is_open() or _end_overlay.is_open() \
or node_overlay.visible or level_screen.visible or shop_overlay.visible or tune_panel.visible \
or _start_overlay.visible or _inter_overlay.visible or confirm_dialog.visible or settlement == null
# 模态/非战斗期棋盘输入锁(T3 收口批——开态查询归 modal_stack.has_open(),登记册单一来源,
# 不再手工罗列各层;例外三件:开始层/过场层(非模态全屏态,不在登记册)与战斗缺位(棋盘输入无载体))
return _modals.has_open() or _start_overlay.visible or _inter_overlay.visible or settlement == null
# ==================== 冒险模式入口(唯一试玩入口——单关测试直入口退役 2026-09-23 用户拍板)====================
@@ -335,7 +375,7 @@ func _resume_from_menu() -> void:
log_view.clear()
logger.start_session(int(bp.get("master_seed", 0)))
adventure.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct())
if not adventure.resume_adventure(bp, {"hero_override": meta.hero_override() if meta != null else {}, "aux_unlocked": _aux_unlocked_set()}):
if not adventure.resume_adventure(bp, {"hero_override": meta.hero_override() if meta != null else {}, "aux_unlocked": _aux.unlocked_set()}):
_fatal_screen("续玩恢复失败——见输出日志")
return
@@ -373,7 +413,7 @@ func _start_adventure() -> void:
# 永久强化注入(元进度实现批):冒险开始参数面——meta 现算 override 显式传参(全零 = 白板)
# 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签
var first_script: bool = not save_manager.is_first_adventure_done()
if not adventure.start_adventure(seed_val, {"hero_override": meta.hero_override() if meta != null else {}, "first_script": first_script, "aux_unlocked": _aux_unlocked_set()}):
if not adventure.start_adventure(seed_val, {"hero_override": meta.hero_override() if meta != null else {}, "first_script": first_script, "aux_unlocked": _aux.unlocked_set()}):
_fatal_screen("冒险启动失败——见输出日志")
return
@@ -558,9 +598,7 @@ func _on_adventure_finished(summary: Dictionary) -> void:
settlement = null
_exit_level_screen() # 终局兜底:独立屏在开态(放弃入口)也先回战斗屏呈现摘要——屏级互斥单一出口
_sync_settlement_visuals() # → 全关
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体 2026-09-26 迁 B 区站桩)
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
stage.settle_hero_idle() # 主角态回 idle(迭代 6 T3 三态收尾)
HudPresenter.battle_end_feedback(stage, ap_bar, false) # 灰化/idle 回正/状态全清——HUD 单通道(T3 收口批)
# 存档入账(元进度实现批):settle = Boss 两档入账 + 工坊解锁 + 摘要入档 + 写盘(唯一写档点不变——
# 调试两层不经过本处理器,天然不写档不污染生涯数据)。
var detail: Dictionary[String, Variant] = {}
@@ -664,24 +702,26 @@ func _on_inter_next_pressed() -> void:
# E4:加成生效值供给(debug_session 注入 Callable——每次从面板百分比现算,经 encounter 传参零表写;
# 原表写路径 _apply_hero_override/_scaled_override 已废除,双写叠加 6→18→54 整类隐患随之移除)
# 原表写路径 _apply_hero_override/_scaled_override 已废除,双写叠加 6→18→54 整类隐患随之移除;
# 合成式单点在 UiKit.scaled——与 TunePanel 生效值预览共用,本类不再持有同式私有实现)
func _debug_hero_override() -> Dictionary:
return {"atk": _scaled(int(db.hero.atk), tune_panel.atk_pct()),
"max_hp": _scaled(int(db.hero.max_hp), tune_panel.hp_pct())}
return {"atk": UiKit.scaled(int(db.hero.atk), tune_panel.atk_pct()),
"max_hp": UiKit.scaled(int(db.hero.max_hp), tune_panel.hp_pct())}
func _scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音)
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音;
# 相位取数经 presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透,T3 收口批)
func _identity_lookup(c: Vector2i):
if settlement == null or settlement.minesweeper.current_board() == null or settlement.action_economy.phase != settlement.action_economy.PHASE_ACTIVE:
if settlement == null or settlement.minesweeper.current_board() == null:
return null
if String(settlement.presentation_snapshot().get("phase", "")) != ActionEconomy.PHASE_ACTIVE:
return null
return settlement.query_reward_identity(c)
# 地形变异 数字遮蔽解析(信息型 hide_number——格带该效果变异即遮蔽;读取方按格子变异数据行动)
# 地形变异 数字遮蔽解析(信息型 hide_number——格带该效果变异即遮蔽;读取方按格子变异数据行动。
# 变异表直读本根 db——settlement.db 即组合根注入的同一 ConfigDB 实例(AdventureRunner/DebugSession
# 构造传参),免 settlement 内部链式穿透,T3 收口批)
func _variant_mask_lookup(c: Vector2i) -> bool:
if settlement == null:
return false
@@ -689,14 +729,14 @@ func _variant_mask_lookup(c: Vector2i) -> bool:
if b == null:
return false
for vid in b.variant_ids(c):
var vrow: Dictionary = settlement.db.variation.row_by_id(String(vid))
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
# 地形变异 角标解析(迭代 6 T6——伤害型变异未开格视觉标记;读取方按格子变异数据行动,规则本体零特判。
# 策略纹理非数值墙:标记=玩家绕行/承担的决策输入,难度本体仍住数值包络)
# 策略纹理非数值墙:标记=玩家绕行/承担的决策输入,难度本体仍住数值包络。变异表直读本根 db——同上款收口)
func _variant_mark_lookup(c: Vector2i) -> String:
if settlement == null:
return ""
@@ -704,7 +744,7 @@ func _variant_mark_lookup(c: Vector2i) -> String:
if b == null:
return ""
for vid in b.variant_ids(c):
var vrow: Dictionary = settlement.db.variation.row_by_id(String(vid))
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "flat_damage":
return "flat_damage"
return ""
@@ -720,10 +760,7 @@ func _on_battle_finished(payload: Dictionary) -> void:
# 仅调试模式接线(冒险的关卡结束归 AdventureRunner._on_level_finished——大奖励/冒险终局分派)
var result: String = String(payload.get("result", ""))
_sync_settlement_visuals() # → 全关(battle_over 反馈面)
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
stage.settle_hero_idle() # 主角态回 idle(迭代 6 T3 三态收尾)
ap_bar.set_rap(0) # RAP 清零脉冲 + 微文案(载体 2026-09-26 迁 D 区 AP 条)
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体迁 B 区站桩)
HudPresenter.battle_end_feedback(stage, ap_bar, true) # 灰化/idle 回正/RAP 清零/状态全清——HUD 单通道(T3 收口批)
var decision: Dictionary = _debug.on_battle_finished(payload)
match String(decision.outcome):
"next_layer":
@@ -762,130 +799,34 @@ func _wire_battle(settlement_ref) -> void:
settlement_ref.board_changed.connect(_on_board_changed)
# ==================== 结算行排播(泵本体归 row_pacer.gd——本节仅入口与探针/测试委托面)====================
func _on_step_row(row: Dictionary) -> void:
# 卡⑦ 排播入口:减动效=延迟归零同帧直落(行呈现不跳过);常态入队按段阶梯播放
if UiKit.motion_scale < 1.0:
_consume_row(row)
_row_pacer.consume_now(row)
return
_row_queue.append(row)
_row_pacer.enqueue(row)
# 行消费(排播队列的出队步 / 减动效快路共用):明细带入带+演出链+呈现刷新——与卡⑦前排位逐位一致
func _consume_row(row: Dictionary) -> void:
if row.has("__round_line"):
log_view.finish_settlement_round() # 上轮摘要行(明细带 pattern 3)随轮首分隔行落带
log_view.append_bb("[color=#e0b860]── 第 %d 轮 ──[/color]" % int(row["__round_line"]))
return
log_view.append_row(row)
stage.on_step_row_fx(row)
_play_cross_zone_fx(row) # 跨区因果演出(结算演出链批):开格之因 → 站桩之果
_play_settlement_mark(row) # 三轮 #3——逐条标出:Buff/Debuff 色环脉冲 → 伤害/治疗数字浮现
# 高光演出(战斗结算×3①② 占位):完美排雷横幅 / 标中旗加重弹道
if String(row.get("type", "")) == "highlight":
board_zone.fx.play_perfect_sweep()
elif String(row.get("highlight", "")) == "flag_hit":
board_zone.fx.play_flag_hit_projectile(true)
_refresh_presentations()
# 逐条结算标出(三轮 #3——用户裁决按结算顺序演出:先 Buff/Debuff 特效、再基础伤害数字;
# 打中谁标谁、主角被打标主角;道具/奖励行逐条冒出)。目标解析失败兜底棋盘中心。
func _play_settlement_mark(row: Dictionary) -> void:
var mark_type := String(row.get("type", ""))
if mark_type != "effect" and mark_type != "damage" and mark_type != "heal":
return
var target := String(row.get("target", ""))
var center: Vector2
if target == "hero":
center = stage.hero_standee_center()
else:
var v = stage.enemy_standee_center(target) if target != "" and target != "all_enemies" else null
center = v if v != null else board_view.get_global_rect().get_center()
match mark_type:
"effect":
var col: Color = Color("7cc47c") if int(row.get("value", 0)) > 0 else Color("c47c7c")
settlement_fx.play_status_pulse(center, col)
"damage":
settlement_fx.play_damage_number(center, "-%d" % int(row.get("value", 0)),
Color("d9402a"), bool(row.get("crit", false)))
"heal":
settlement_fx.play_damage_number(center, "+%d" % int(row.get("value", 0)), Color("7cc47c"), false)
func _process(delta: float) -> void:
# 排播泵(卡⑦——演出层帧驱动;队列空=零开销直返)
if _row_queue.is_empty():
_row_pace_wait = 0.0
_pace_prev_segment = ""
if _was_pumping:
# 演播完边沿:结算视觉期由排播队列承载(_is_settling 排播项),队列排空的翻转
# 无核心信号驱动——此处主动刷回挖掘态(去饱和解除/明细带收起/mode=dig)。
_was_pumping = false
_refresh_presentations.call_deferred()
# 锁期点击重放(v3.1——玩家操作零丢失):最后一次锁期点击延迟一帧重入
# _on_board_action(此刻队列已空=锁已解,正常受理)。
if not _locked_click.is_empty():
_replay_locked_click.call_deferred()
return
_was_pumping = true
_row_pace_wait -= delta
if _row_pace_wait > 0.0:
return
var entry: Dictionary = _row_queue.pop_front()
_consume_row(entry)
var seg := String(entry.get("type", ""))
_row_pace_wait = 0.0 if UiKit.motion_scale < 1.0 else (PACE_SEGMENT_GAP if seg != _pace_prev_segment else PACE_ROW_GAP)
_pace_prev_segment = seg
# 段间延迟取数面(减动效双态断言用——开=0 归零,行不跳过;常态=段间/段内两档)
# 段间延迟取数面(减动效双态断言用——pacing_queue_test 直读;委托 row_pacer 同名面)
func pace_delay_for(prev_seg: String, next_seg: String) -> float:
if UiKit.motion_scale < 1.0:
return 0.0
return PACE_SEGMENT_GAP if prev_seg != next_seg else PACE_ROW_GAP
return _row_pacer.pace_delay_for(prev_seg, next_seg)
# 强制排空(模态开启前调用——明细回看/停点覆盖层/终局面所见明细带恒完整)
# 强制排空(模态开启前调用——明细回看/停点覆盖层/终局面所见明细带恒完整;pacing_queue_test 直读)
func _flush_row_queue() -> void:
while not _row_queue.is_empty():
_consume_row(_row_queue.pop_front())
_row_pace_wait = 0.0
_pace_prev_segment = ""
# 跨区因果演出(§Transitions 结算逐行兑现——动画密度终稿归战斗表现样张批):
# 伤害行 → ⚔ 弹道自棋盘飞向目标敌方站桩(crit 金档加重);治疗行 → ✚ 弹道飞向主角站桩;
# 命中反馈(闪白/血条递减/灰化)由站桩侧 on_step_row_fx/set_data 承接
func _play_cross_zone_fx(row: Dictionary) -> void:
var from_v: Vector2 = board_view.get_global_rect().get_center()
match String(row.get("type", "")):
"damage":
var target := String(row.get("target", ""))
var crit := bool(row.get("crit", false))
if target == "" or target == "hero":
# 主角受击弹道(迭代 6 T5 拆「敌→主角零反馈」早退):⚔ 危险红飞向主角站桩;
# crit 只加重字号不加金(金=收益/胜利语义位,不用于敌方对主角输出)
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "⚔",
UiKit.RED_MINE, crit)
return
var to_v = stage.enemy_standee_center(target)
if to_v != null:
settlement_fx.play_projectile(from_v, to_v, "⚔",
UiKit.GOLD if crit else UiKit.RED_MINE, crit)
"heal":
if String(row.get("target", "")) != "hero" and String(row.get("target", "")) != "":
return
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "✚", Color("7cc47c"))
_row_pacer.flush()
func _on_round_started(ri: int) -> void:
# 卡⑦:轮首分隔行随排播队列走(落在前轮各行之后——带序正确);轮横幅即轮切换时刻发射
#(view 内判减动效=静态化变体)。减动效快路=分隔行同帧直落(不跳过)。
#(view 内判减动效=静态化变体)。减动效快路=分隔行同帧直落(不跳过——consume_now 复用
# _consume_row 轮首分隔行分支,与原直写逐字等价)。
if UiKit.motion_scale < 1.0:
log_view.finish_settlement_round()
log_view.append_bb("[color=#e0b860]── 第 %d 轮 ──[/color]" % ri)
_row_pacer.consume_now({"__round_line": ri})
else:
_row_queue.append({"__round_line": ri})
_row_pacer.enqueue({"__round_line": ri})
settlement_fx.play_round_banner(ri)
_refresh_used_round = 0
_refresh_presentations()
@@ -1013,12 +954,13 @@ func _open_camp() -> void:
return
meta.refresh_workshop_unlocks()
_start_overlay.visible = false
_open_modal(camp_overlay) # 模态栈单口(T3 收口批——登记层开合一律走栈)
camp_overlay.open_camp(_camp_data())
_refresh_presentations()
func _on_camp_closed() -> void:
camp_overlay.visible = false
_close_modal(camp_overlay) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新
_refresh_presentations() # 关闭后刷新顶栏(工坊解锁可能变了余额侧读数面)
@@ -1028,11 +970,11 @@ func _on_camp_closed() -> void:
func _open_settings() -> void:
_start_overlay.visible = false
settings_overlay.visible = true
_open_modal(settings_overlay) # 模态栈单口(T3 收口批)
func _on_settings_closed() -> void:
settings_overlay.visible = false
_close_modal(settings_overlay) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button()
@@ -1090,116 +1032,12 @@ func _on_shop_leave() -> void:
runner.continue_after_node()
# ---- 魔法道具 HUD 接线(迭代 7 T4——库存条+两步交互+叠加层呈现;数据源=runner 级库存只读面)----
# ---- 奖励节点三选一(迭代 7 T5——拍板②丙:奖励屏=小构筑点;「节点奖励=功能道具正式渠道」兑现)----
# aux 解锁门控集组装(迭代 7 T5——拍板③甲:工坊解锁 ∩ 活行词表;罗盘退役/失义件不入集)
func _aux_unlocked_set() -> Dictionary:
var out := {}
if meta == null:
return out
for row in db.aux_active_rows():
var iid := String(row.get("item_id", ""))
if meta.save.item_unlocked(iid):
out[iid] = true
return out
# 槽点击(第一步):AP 类(target=none)点击即用(无目标);空间类进/出选中态(toggle 取消)。
func _on_aux_slot_clicked(slot_handle: int) -> void:
if settlement == null or runner == null or runner.aux_inventory == null:
return
if slot_handle == 0:
_aux_use_selection = 0
_refresh_aux_slots()
return
var rec: Dictionary = {}
for r in _aux_inventory_projection():
if int(r.get("handle", 0)) == slot_handle:
rec = r
break
if rec.is_empty() or bool(rec.get("used", false)):
return
var arow: Dictionary = db.aux_item_row(String(rec.get("item_id", "")))
if String(arow.get("target", "none")) == "none":
_attempt_aux_use(slot_handle, Vector2i(-9, -9)) # AP 类:无目标即用
return
if not bool(rec.get("usable", true)):
board_zone.toast.show_toast("当前无可预读的未开格", "warn")
return
_aux_use_selection = 0 if _aux_use_selection == slot_handle else slot_handle # toggle 取消
_refresh_aux_slots()
if _aux_use_selection != 0:
board_zone.toast.show_toast("%s:点击目标格使用(9 宫窗口),再点道具取消" % String(rec.get("name", "")), "info")
# 使用受理(第二步;core 侧 use_aux 四步编排——拒绝零消耗 toast 可辨认)
func _attempt_aux_use(handle: int, cell: Vector2i) -> void:
if settlement == null:
return
var v: Dictionary = settlement.use_aux(handle, cell)
if not bool(v.ok):
board_zone.toast.show_toast(String(v.get("reason", "无法使用")), "warn")
return
_aux_use_selection = 0
_refresh_aux_slots()
_refresh_aux_overlays()
var arow: Dictionary = db.aux_item_row(String(v.get("item_id", "")))
board_zone.toast.show_toast("已使用 %s" % String(arow.get("display_name", "")), "info")
# 库存投影(C 区四槽取数面——字形=名首字;usable=板级零信息预判回调接排雷玩法只读面)
func _aux_inventory_projection() -> Array:
if runner == null or runner.aux_inventory == null:
return []
var ms = settlement.minesweeper if settlement != null else null
var cb := Callable()
if ms != null:
cb = func(_c: Vector2i) -> bool:
for y in ms.current_board().height:
for x in ms.current_board().width:
if not ms.is_revealed(Vector2i(x, y)):
return true
return false
var out: Array = []
for rec in runner.aux_inventory.query_inventory(cb):
var arow: Dictionary = db.aux_item_row(String(rec.get("item_id", "")))
var dn := String(arow.get("display_name", ""))
out.append({
"handle": int(rec.get("handle", 0)),
"item_id": String(rec.get("item_id", "")),
"glyph": dn.left(1) if dn != "" else "?",
"name": dn,
"used": bool(rec.get("used", false)),
"usable": bool(rec.get("usable", true)),
})
return out
func _refresh_aux_slots() -> void:
info_panel.set_aux_inventory(_aux_inventory_projection(), _aux_use_selection)
# 叠加层投影注入(use 成功/三清后——棋盘标记与预读徽章重算)
func _refresh_aux_overlays() -> void:
if runner == null or runner.aux_inventory == null or board_view == null:
return
var ov: Dictionary = runner.aux_inventory.query_overlays()
board_view.set_aux_overlays(ov.get("mine_reveal", []), ov.get("reward_preview", []))
func _on_wallet_changed(event: String, gp: int, extra: Dictionary) -> void:
# wallet 行族转发(到账/购买/清零——试玩日志验收取数面)+ 常驻余额与在开商店覆盖层重刷
logger.log_wallet(event, gp, extra)
match String(event): # GP 影子账(迭代 6 T7——收束清零前取值,纯呈现零核心触碰=C1 呈现层方案)
"gain", "buy":
_adventure_gp_view = gp
if String(event) == "gain":
_last_gain_amount = int(extra.get("amount", 0)) # 卡⑥ GP 增量取数(每战恰一笔 gain)
"clear":
_adventure_gp_final = _adventure_gp_view
_adventure_gp_view = 0
_last_gain_amount = 0
_gp_ledger.apply_event(event, gp) # GP 影子账(迭代 6 T7——收束清零前取值,纯呈现零核心触碰=C1;账本归 gp_shadow_ledger.gd)
if String(event) == "gain":
_last_gain_amount = int(extra.get("amount", 0)) # 卡⑥ GP 增量取数(每战恰一笔 gain)
if shop_overlay.visible and runner != null:
shop_overlay.refresh_state(runner.shop_products(), int(runner.gp))
_refresh_presentations()
@@ -1212,7 +1050,7 @@ func _on_board_action(cell: Vector2i, kind: String) -> void:
if _input_locked():
return
# 结算演播期棋盘冻结(T17 用户明确令 v3——不可点/不可移/不可挖):
# 锁面=排播队列活跃(纯队列判据;_row_pace_wait 有末行尾迹窗口不可作演播中标志)。
# 锁面=排播队列活跃(纯队列判据;排播泵尾迹窗口不可作演播中标志——见 row_pacer._process 注)。
# 队列排空即解锁、无状态残留;HUD 按钮不在锁面(停手/明细回看各自通路,开启前
# _flush_row_queue 自行排空)。
# **点击不丢弃**(v3.1——用户实机「卡死」根因):锁期点击缓存最后一次,演播完边沿
@@ -1222,14 +1060,15 @@ func _on_board_action(cell: Vector2i, kind: String) -> void:
return
# 魔法道具选中态劫持(迭代 7 T4——两步交互第二步):点格=使用目标拾取,不落挖格;
# 只劫持挖掘点击(move/flag 语义不冲突——选中期玩家仍可移动/插旗)
if _aux_use_selection != 0 and kind == "dig":
_attempt_aux_use(_aux_use_selection, cell)
var aux_sel: int = _aux.active_selection()
if aux_sel != 0 and kind == "dig":
_aux.attempt_use(aux_sel, cell)
return
var outcomes: Array = settlement.minesweeper.settle_batch([{"action": kind, "cell": cell}])
var accepted: bool = not outcomes.is_empty() and bool(outcomes[0].accepted)
logger.log_action(kind, cell, accepted)
if runner != null and runner.aux_inventory != null:
_refresh_aux_slots() # 迭代 7 T4——授予发生在 settle_batch 结算段内(同步走完,行尾重查)
_aux.refresh_slots() # 迭代 7 T4——授予发生在 settle_batch 结算段内(同步走完,行尾重查)
_handle_outcome_feedback(outcomes[0] if not outcomes.is_empty() else {})
# settle_batch 内部同步走完 结算→战斗结束→关卡推进(事件同步发射)——关卡终局时 settlement 已被
# runner 置空,清图演出判定须先判 settlement(基线既有重入隐患,2026-09-13 加固)
@@ -1278,7 +1117,7 @@ func _on_refresh_confirmed() -> void:
board_zone.toast.show_toast("RAP−%d · 旧板旗作废——新图已就位,AP 保留继续挖" % int(db.action_economy.rap_refresh_cost), "gold")
else:
# 资源面/上限拒绝(EC13 合法拒绝可见;行动经济 无载荷——客户端按上下文推断)
var rap: int = int(settlement.action_economy.rap) if settlement != null else 0
var rap: int = int(settlement.presentation_snapshot().get("rap", 0)) if settlement != null else 0
var cost: int = int(db.action_economy.rap_refresh_cost)
var result := "rejected_rap" if rap < cost else "rejected_cap"
logger.log_ux("ux_refresh_attempt", {"result": result, "round_refresh_index": _refresh_used_round + 1})
@@ -1315,34 +1154,32 @@ func _sync_settlement_visuals() -> void:
# 结算阶段(round_end → 下一轮):D 区去饱和+金橙偏移锁输入(明度不变)+ 明细带展开 84px
var settling := _is_settling()
if settling and not _was_settling:
settlement_fx.play_round_sweep(board_view.get_global_rect()) # 结算轮光带(演出链批)
settlement_fx.play_round_sweep(board_view.get_global_rect()) # 结算轮光带(演出链批)——时序敏感演出触发,例外留在组合根
_was_settling = settling
board_view.set_desaturated(settling)
board_view.set_dig_glow_enabled(not settling)
stage.set_settle_focus(settling)
log_view.set_mode("settle" if settling else "dig")
stage.set_round_text(_phase_text())
HudPresenter.sync_settlement_visuals(stage, board_view, log_view, settling, _phase_text())
func _is_settling() -> bool:
# T17 修复:结算视觉期跟随排播演播到放完。核心层同步走完(轮末结算后逻辑秒回 Active),
# 旧判定让演播期棋盘提前恢复可挖观感=用户「没意识到在结算演出」感知缺口的根源;
# 演播期维持去饱和+禁挖光(结算中语义),放完自然回挖掘认知。判据=纯队列(_row_pace_wait
# 在末行消费后到下帧泵清零前有 >0 尾迹窗口,不可作演播中标志)。battle 收尾演播例外:
# 演播期维持去饱和+禁挖光(结算中语义),放完自然回挖掘认知。判据=纯队列(排播泵尾迹
# 窗口不可作演播中标志——row_pacer._process 注)。battle 收尾演播例外:
# overlay 开启即 _flush_row_queue 排空,视觉随队列空同步交接结算框。
# 相位取数经 presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透(T3 收口批)。
if settlement == null or not settlement.is_battle_live():
return not _row_queue.is_empty()
if settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING:
if String(settlement.presentation_snapshot().get("phase", "")) == ActionEconomy.PHASE_WAITING:
return true
return not _row_queue.is_empty()
func _phase_text() -> String:
# 文案装配归 narrator(settlement 缺位 = 非战斗态——组合根模式状态守卫)
# 文案装配归 narrator(settlement 缺位 = 非战斗态——组合根模式状态守卫;相位取数经
# presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透,T3 收口批)
if settlement == null:
return "第 — 轮 · —"
return Narrator.phase_text(int(settlement.round_index), String(settlement.battle_result),
settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING,
String(settlement.presentation_snapshot().get("phase", "")) == ActionEconomy.PHASE_WAITING,
_boss_active, Narrator.boss_name(runner, db))
@@ -1377,11 +1214,11 @@ func _on_diagnose_pressed() -> void:
func _show_tune_panel() -> void:
_start_overlay.visible = false
tune_panel.visible = true
_open_modal(tune_panel) # 模态栈单口(T3 收口批)
func _close_tune_panel() -> void:
tune_panel.visible = false
_close_modal(tune_panel) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新
+9
View File
@@ -32,3 +32,12 @@ func close(m: Control) -> void:
if is_instance_valid(h):
h.visible = true
_occluded.erase(m)
# 开态查询(T3 模态收口批 2026-10-02——组合根输入锁唯一判据:登记册上是否有任一层处于开态;
# 可见性即开态,与 open/close 记账同源——组合根不再手工罗列各层)
func has_open() -> bool:
for m in _list:
if is_instance_valid(m) and m.visible:
return true
return false
+149
View File
@@ -0,0 +1,149 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 结算行排播泵(迭代 8 卡⑦;T3 拆件批 2026-10-02 自 main.gd 整体迁出——规范 1.1 单一职责):
# 演出层排播:逻辑先行零回压,行按段分组阶梯延迟进明细带。
# 硬契约:核心层零 await 不破——本队列只做信号的"后置消费",结算顺序/结果/时序逻辑零改动(§3 契约 1);
# 减动效开=延迟归零走同帧直落快路(行呈现不跳过——§3 契约 5);模态开启前强制排空(明细回看/停点
# 覆盖层所见明细带恒完整——排空入口在组合根 _open_modal)。
# 装配纪律:视图句柄与呈现刷新回调由组合根注入(ADR-0001——本类零信号连接,仅做信号后置消费);
# 行消费与卡⑦前逐位一致(_consume_row 原文迁入,语义零改动)。
extends Node
const PACE_SEGMENT_GAP := 0.3 # 段间阶梯延迟(代拍板:白名单 0.2–0.4s 内取值,追认清单呈报)
const PACE_ROW_GAP := 0.22 # 段内间隔(三轮 #3 上调 0.06→0.22——逐条演出可感知,配合数字浮现/状态脉冲)
var _rows: Array = [] # 待排播行(step_row 载荷原样入队;__round_line 标记=轮首分隔行)
var _pace_wait := 0.0 # 距下一行排播的剩余秒(_process 消耗)
var _prev_segment := "" # 上一排播行的段型(段间/段内延迟判据)
var _was_pumping := false # 排播活跃边沿(T17——末行消费帧触发结算视觉回挖掘态刷新)
# 组合根注入面(_build_ui 装配——呈现句柄 + 演播完边沿的呈现刷新回调)
var log_view: BattleLogView
var stage: BattleStageView
var board_zone: BoardZoneView
var settlement_fx: SettlementFxView
var board_view: BoardView
var refresh_request := Callable() # () → 组合根 _refresh_presentations(行尾直呼/演播完边沿 call_deferred)
var pump_ended := Callable() # () → 组合根锁期点击重放入口(T17 v3.1——演播完边沿 call_deferred;空回调安全)
# 队列只读面(探针/测试经组合根委托属性读——共享同一 Array 引用,读数即实况)
func pending_rows() -> Array:
return _rows
# 入队(常态路径——减动效关;step_row 载荷原样,零加工)
func enqueue(row: Dictionary) -> void:
_rows.append(row)
# 同帧直落(减动效快路——延迟归零,行呈现不跳过;轮首分隔行减动效分支同用此口)
func consume_now(row: Dictionary) -> void:
_consume_row(row)
# 段间延迟取数面(减动效双态断言用——开=0 归零,行不跳过;常态=段间/段内两档)
func pace_delay_for(prev_seg: String, next_seg: String) -> float:
if UiKit.motion_scale < 1.0:
return 0.0
return PACE_SEGMENT_GAP if prev_seg != next_seg else PACE_ROW_GAP
# 强制排空(模态开启前调用——明细回看/停点覆盖层/终局面所见明细带恒完整)
func flush() -> void:
while not _rows.is_empty():
_consume_row(_rows.pop_front())
_pace_wait = 0.0
_prev_segment = ""
func _process(delta: float) -> void:
# 排播泵(卡⑦——演出层帧驱动;队列空=零开销直返)
if _rows.is_empty():
_pace_wait = 0.0
_prev_segment = ""
if _was_pumping:
# 演播完边沿:结算视觉期由排播队列承载(_is_settling 排播项),队列排空的翻转
# 无核心信号驱动——此处主动刷回挖掘态(去饱和解除/明细带收起/mode=dig)。
_was_pumping = false
refresh_request.call_deferred()
# 锁期点击重放(T17 v3.1——玩家操作零丢失):最后一次锁期点击延迟一帧重入
# _on_board_action(此刻队列已空=锁已解,正常受理;重放入口由组合根注入)。
if pump_ended.is_valid():
pump_ended.call_deferred()
return
_was_pumping = true
_pace_wait -= delta
if _pace_wait > 0.0:
return
var entry: Dictionary = _rows.pop_front()
_consume_row(entry)
var seg := String(entry.get("type", ""))
_pace_wait = 0.0 if UiKit.motion_scale < 1.0 else (PACE_SEGMENT_GAP if seg != _prev_segment else PACE_ROW_GAP)
_prev_segment = seg
# 行消费(排播队列的出队步 / 减动效快路共用):明细带入带+演出链+呈现刷新——与卡⑦前排位逐位一致
func _consume_row(row: Dictionary) -> void:
if row.has("__round_line"):
log_view.finish_settlement_round() # 上轮摘要行(明细带 pattern 3)随轮首分隔行落带
log_view.append_bb("[color=#e0b860]── 第 %d 轮 ──[/color]" % int(row["__round_line"]))
return
log_view.append_row(row)
stage.on_step_row_fx(row)
_play_cross_zone_fx(row) # 跨区因果演出(结算演出链批):开格之因 → 站桩之果
_play_settlement_mark(row) # 三轮 #3——逐条标出:Buff/Debuff 色环脉冲 → 伤害/治疗数字浮现
# 高光演出(战斗结算×3①② 占位):完美排雷横幅 / 标中旗加重弹道
if String(row.get("type", "")) == "highlight":
board_zone.fx.play_perfect_sweep()
elif String(row.get("highlight", "")) == "flag_hit":
board_zone.fx.play_flag_hit_projectile(true)
refresh_request.call()
# 逐条结算标出(三轮 #3——用户裁决按结算顺序演出:先 Buff/Debuff 特效、再基础伤害数字;
# 打中谁标谁、主角被打标主角;道具/奖励行逐条冒出)。目标解析失败兜底棋盘中心。
func _play_settlement_mark(row: Dictionary) -> void:
var mark_type := String(row.get("type", ""))
if mark_type != "effect" and mark_type != "damage" and mark_type != "heal":
return
var target := String(row.get("target", ""))
var center: Vector2
if target == "hero":
center = stage.hero_standee_center()
else:
var v = stage.enemy_standee_center(target) if target != "" and target != "all_enemies" else null
center = v if v != null else board_view.get_global_rect().get_center()
match mark_type:
"effect":
var col: Color = Color("7cc47c") if int(row.get("value", 0)) > 0 else Color("c47c7c")
settlement_fx.play_status_pulse(center, col)
"damage":
settlement_fx.play_damage_number(center, "-%d" % int(row.get("value", 0)),
Color("d9402a"), bool(row.get("crit", false)))
"heal":
settlement_fx.play_damage_number(center, "+%d" % int(row.get("value", 0)), Color("7cc47c"), false)
# 跨区因果演出(§Transitions 结算逐行兑现——动画密度终稿归战斗表现样张批):
# 伤害行 → ⚔ 弹道自棋盘飞向目标敌方站桩(crit 金档加重);治疗行 → ✚ 弹道飞向主角站桩;
# 命中反馈(闪白/血条递减/灰化)由站桩侧 on_step_row_fx/set_data 承接
func _play_cross_zone_fx(row: Dictionary) -> void:
var from_v: Vector2 = board_view.get_global_rect().get_center()
match String(row.get("type", "")):
"damage":
var target := String(row.get("target", ""))
var crit := bool(row.get("crit", false))
if target == "" or target == "hero":
# 主角受击弹道(迭代 6 T5 拆「敌→主角零反馈」早退):⚔ 危险红飞向主角站桩;
# crit 只加重字号不加金(金=收益/胜利语义位,不用于敌方对主角输出)
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "⚔",
UiKit.RED_MINE, crit)
return
var to_v = stage.enemy_standee_center(target)
if to_v != null:
settlement_fx.play_projectile(from_v, to_v, "⚔",
UiKit.GOLD if crit else UiKit.RED_MINE, crit)
"heal":
if String(row.get("target", "")) != "hero" and String(row.get("target", "")) != "":
return
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "✚", Color("7cc47c"))
+1
View File
@@ -0,0 +1 @@
uid://68efaexi7rfr
+19 -6
View File
@@ -1,29 +1,41 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-12(code-review 修复批——自 main.gd 拆出,规范 1.1/1.2 模拟编排独立成类)
# 调整板批量模拟控制器:逐次驱动 LevelAutoplay(与 headless 扫描同口径)+ 进度/报告 setter 注入面板。
# 调整板批量模拟控制器:逐次驱动 关卡自动策略(与 headless 扫描同口径)+ 进度/报告 setter 注入面板。
# 职责边界:零核心写——表基线复位由组合根在移交前完成(_apply_hero_override(0,0)),
# 加成施加 = LevelAutoplay 内部 runner.set_hero_override 单路径;报告文案装配归 HudPresenter。
# 加成施加 = 自动策略内部 runner.set_hero_override 单路径;报告文案装配归 HudPresenter。
# 组合根只转发 simulate_requested 信号。await 依赖面板所在场景树(进度逐帧刷新)。
# 反向依赖解除(2026-10-02 修复批 T6):presentation → smoke 的 preload 编译期依赖拆除——
# 驱动实例由调用方构造注入(_driver,须供 run_level(...) 与 smoke/level_autoplay 同款签名);
# 构造参数缺省 null = 内部自建旧行为(运行时 load 兜底,同种子同口径零漂移)——main 侧接线归主控后续卡。
extends RefCounted
const SEED_BASE := 4200 # 模拟种子基(与历史实现同源——同种子可回放)
const LevelAutoplay := preload("res://smoke/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3:preload 编译期路径校验)
const PlaytestLogger := preload("res://presentation/playtest_logger.gd") # 试玩日志(同层依赖——运行时 load 收敛为 preload 常量)
var db
var panel # TunePanel setter 面:atk_pct/hp_pct/set_sim_running/set_sim_progress/set_sim_report
var _base_hp := 0 # 报告中的"基础 HP"口径注记值
var _driver = null # 关卡自动策略驱动(调用方注入;null = 旧行为运行时 load 兜底)
func _init(db_ref, panel_ref, base_hp: int) -> void:
func _init(db_ref, panel_ref, base_hp: int, driver = null) -> void:
db = db_ref
panel = panel_ref
_base_hp = base_hp
_driver = driver
# 驱动解析:注入优先;缺省回退运行时 load(旧行为——编译期反向依赖已解除,运行时口径与拆分前逐位一致)
func _autoplay():
if _driver != null:
return _driver
return load("res://smoke/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3 兜底路径)
func run(n: int) -> void:
# 共享日志会话(2026-09-12 用户要求:模拟运行与真人试玩同日志合规——操作/轮次/战斗/通关全量 JSONL;
# 一文件多局,run 行携带种子切分局次)
var logger = load("res://presentation/playtest_logger.gd").new()
var logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
var log_path: String = logger.start_session(SEED_BASE)
print("[tune_sim] 模拟日志: %s" % log_path)
@@ -35,8 +47,9 @@ func run(n: int) -> void:
var hp_boss: Array[float] = []
var rounds_all: Array[int] = []
var death_nodes := {}
var autoplay = _autoplay()
for i in range(n):
var st = LevelAutoplay.run_level(db, SEED_BASE + i, 1, panel.atk_pct(), panel.hp_pct(), false, logger)
var st = autoplay.run_level(db, SEED_BASE + i, 1, panel.atk_pct(), panel.hp_pct(), false, logger)
dmg_all.append(st.hero_dmg)
rounds_all.append(st.rounds)
if st.result == "victory":
+8 -11
View File
@@ -114,18 +114,17 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary:
bd.mouse_filter = Control.MOUSE_FILTER_IGNORE
overlay.add_child(bd)
var box := overlay_box(overlay)
var title := UiKit.label("Dungeon Sweeper", 34, true)
title.add_theme_color_override("font_color", UiKit.WARM)
var title := UiKit.label("Dungeon Sweeper", 34, true, UiKit.WARM)
box.add_child(title)
var sub := UiKit.label("战斗屏战斗屏改造批 —— 扫雷的因,逐行兑现的果", 16, false)
sub.add_theme_color_override("font_color", UiKit.WARM) # 对比度修复(v3.5 批 B3——WARM_SOFT 对深石底实测 3.54:1→暖白 ≥6:1)
# 对比度修复(v3.5 批 B3——WARM_SOFT 对深石底实测 3.54:1→暖白 ≥6:1)
var sub := UiKit.label("战斗屏战斗屏改造批 —— 扫雷的因,逐行兑现的果", 16, false, UiKit.WARM)
box.add_child(sub)
box.add_child(HSeparator.new())
var seed_row := HBoxContainer.new()
box.add_child(seed_row)
var seed_label := UiKit.label("随机种子:", 16, false) # 字号下限(v3.5 批 B3——正文 ≥16)+暗底显式暖白
seed_label.add_theme_color_override("font_color", UiKit.WARM)
# 字号下限(v3.5 批 B3——正文 ≥16)+暗底显式暖白
var seed_label := UiKit.label("随机种子:", 16, false, UiKit.WARM)
seed_row.add_child(seed_label)
var seed_edit := LineEdit.new()
seed_edit.text = "42"
@@ -153,9 +152,8 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary:
# 玩法帮助文案(表值驱动——2026-09-16 AP 压速批遗留硬编码 8 随手修正;护甲等级掷点批同步"减伤"→"护甲强化")
var help_text := "玩法:每轮 %d AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n每个非封锁格都藏一种格面资源(增伤/护甲强化/回血/爆裂/削弱)。开安全格、标中雷 = 正向兑付;\n开雷格、标错安全格 = 负向兑付——全部在轮末结算逐行兑现;吃掷点的基础攻击按护甲等级判定,无 miss、最低保底 1 点。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−%d 当场换图继续挖(每轮剩 %d 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。" % [
int(db.action_economy.ap_per_round), int(db.action_economy.rap_refresh_cost), int(db.action_economy.refresh_cap_per_round)]
var help := UiKit.label(help_text, 16, false) # 字号下限(v3.5 批 B3——正文 ≥16)
# 对比度修复(v3.5 批 B3——WARM_FAINT 对深石底实测 2.58:1→暖白 ≥6:1;暗底双色墨制)
help.add_theme_color_override("font_color", UiKit.WARM)
# 字号下限(v3.5 批 B3——正文 ≥16);对比度修复(v3.5 批 B3——WARM_FAINT 对深石底实测 2.58:1→暖白 ≥6:1;暗底双色墨制)
var help := UiKit.label(help_text, 16, false, UiKit.WARM)
box.add_child(help)
return {"overlay": overlay, "box": box, "seed_edit": seed_edit,
@@ -167,8 +165,7 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary:
static func build_inter_overlay(host: Control) -> Dictionary:
var overlay := overlay_bg(host, Color(0, 0, 0, 0.6))
var box := overlay_box(overlay)
var label := UiKit.label("", 24, true)
label.add_theme_color_override("font_color", UiKit.WARM)
var label := UiKit.label("", 24, true, UiKit.WARM)
box.add_child(label)
var next_btn := UiKit.button("进入下一层")
box.add_child(next_btn)
+16 -22
View File
@@ -71,8 +71,7 @@ func _init() -> void:
_rap_label = UiKit.label("RAP 0", 14, true)
_rap_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(_rap_label)
_rap_feedback = UiKit.label("", 11, false)
_rap_feedback.add_theme_color_override("font_color", UiKit.STONE_FAINT)
_rap_feedback = UiKit.label("", 11, false, UiKit.STONE_FAINT)
_rap_feedback.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(_rap_feedback)
@@ -81,8 +80,7 @@ func _init() -> void:
_confirm_row.add_theme_constant_override("separation", 4)
_confirm_row.visible = false
row.add_child(_confirm_row)
_confirm_lab = UiKit.label("RAP−%d · 旧板旗作废" % refresh_cost, 14, true)
_confirm_lab.add_theme_color_override("font_color", COL_INK)
_confirm_lab = UiKit.label("RAP−%d · 旧板旗作废" % refresh_cost, 14, true, COL_INK)
_confirm_row.add_child(_confirm_lab)
var confirm_btn := UiKit.button("确认弃图")
confirm_btn.pressed.connect(_disarm_and_fire)
@@ -153,30 +151,26 @@ func set_rap(rap: int) -> void:
_rap_feedback.text = ""
# 资源面拒绝反馈(pattern 1 resource_face 腿):RAP 数字脉冲(RAP<8 弃图刷新被拒)
func pulse_rap() -> void:
# 资源读数拒绝脉冲单点(pattern 1 resource_face 腿——RAP/AP 两份逐字重复合并:朱红脉冲一拍回白)
func _pulse(label: Label) -> void:
var tw := create_tween()
tw.tween_property(_rap_label, "modulate", Color(1.0, 0.4, 0.3), UiKit.motion(0.1))
tw.tween_property(_rap_label, "modulate", Color(1, 1, 1), UiKit.motion(0.25))
tw.tween_property(label, "modulate", Color(1.0, 0.4, 0.3), UiKit.motion(0.1))
tw.tween_property(label, "modulate", Color(1, 1, 1), UiKit.motion(0.25))
func pulse_rap() -> void:
_pulse(_rap_label)
func _render_rap_dots(rap: int) -> void:
var shown := mini(rap, 16)
while _rap_dots.get_child_count() < shown:
var d := ColorRect.new()
d.custom_minimum_size = Vector2(8, 8)
d.size_flags_vertical = Control.SIZE_SHRINK_CENTER
d.mouse_filter = Control.MOUSE_FILTER_IGNORE
_rap_dots.add_child(d)
_rap_dots.add_child(_make_dot(8.0, true)) # RAP 点阵 8px 窄档(随读数字号)——建点单点共用
for i in _rap_dots.get_child_count():
var d: ColorRect = _rap_dots.get_child(i)
d.color = COL_GOLD if i < shown else COL_DOT_OFF
func set_legend_enabled(on: bool) -> void:
_btn_legend.disabled = not on
# 探针/装配复用:刷新按钮可用性(预判 = RAP 足额 + 上限未到)
func is_refresh_ready() -> bool:
return not _btn_refresh.disabled
@@ -226,14 +220,14 @@ func _disarm() -> void:
# 资源面拒绝反馈(pattern 1 resource_face 腿):AP 数字脉冲
func pulse_ap() -> void:
var tw := create_tween()
tw.tween_property(_ap_label, "modulate", Color(1.0, 0.4, 0.3), UiKit.motion(0.1))
tw.tween_property(_ap_label, "modulate", Color(1, 1, 1), UiKit.motion(0.25))
_pulse(_ap_label)
func _make_dot() -> ColorRect:
# 点阵建点单点(AP 10px / RAP 8px 两套建点参数化合并;初始色=熄灭档——投影循环当拍覆写)
func _make_dot(px := 10.0, shrink_center := false) -> ColorRect:
var d := ColorRect.new()
d.custom_minimum_size = Vector2(10, 10)
d.custom_minimum_size = Vector2(px, px)
d.size_flags_vertical = Control.SIZE_SHRINK_CENTER if shrink_center else Control.SIZE_FILL
d.color = COL_DOT_OFF
d.mouse_filter = Control.MOUSE_FILTER_IGNORE
return d
+9 -20
View File
@@ -24,22 +24,13 @@ var _board_rect := Rect2()
func _init() -> void:
# 二轮修改批 B(2026-09-29 用户目检裁决):半透明浮层文字不可读 → 实底石板皮面板形态
#(与营地/设置/图例同族)。半透明氛围 dim 保留(胜利暖纱/失败暮蓝),信息与按钮全部上板;
# 金蜡封 = 面板头部静态徽记(原全屏坠落演出退役——落点被面板遮挡)。
_dim = ColorRect.new()
_dim.color = Color(COL_WARM, 0.0)
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(_dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_dim.add_child(center)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", UiKit.panel_skinned(Vector4(16, 14, 16, 14)))
center.add_child(panel)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(520, 0)
box.add_theme_constant_override("separation", 12)
panel.add_child(box)
#(与营地/设置/图例同族)。半透明氛围 dim 保留(胜利暖纱/失败暮蓝——open_* 就地换色),信息与
# 按钮全部上板;金蜡封 = 面板头部静态徽记(原全屏坠落演出退役——落点被面板遮挡)。
# 骨架经 UiKit.modal_shell(T4 收口批);_dim 引用摘出供 open_* 换氛围色。
var shell := UiKit.modal_shell(self, Color(COL_WARM, 0.0), Vector2(520, 0), 12, true,
UiKit.panel_skinned(Vector4(16, 14, 16, 14)))
_dim = shell.dim
var box: VBoxContainer = shell.box
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 12)
header.alignment = BoxContainer.ALIGNMENT_CENTER
@@ -51,13 +42,11 @@ func _init() -> void:
_seal.custom_minimum_size = Vector2(34, 34)
_seal.size_flags_vertical = Control.SIZE_SHRINK_CENTER
header.add_child(_seal)
_verdict = UiKit.label("", 24, true)
_verdict.add_theme_color_override("font_color", UiKit.INK_TITLE)
_verdict = UiKit.label("", 24, true, UiKit.INK_TITLE)
_verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_verdict.size_flags_vertical = Control.SIZE_SHRINK_CENTER
header.add_child(_verdict)
_gp_line = UiKit.label("", 16, true)
_gp_line.add_theme_color_override("font_color", UiKit.INK_LABEL)
_gp_line = UiKit.label("", 16, true, UiKit.INK_LABEL)
_gp_line.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_gp_line.visible = false
box.add_child(_gp_line)
+14 -17
View File
@@ -20,7 +20,6 @@ const BAND_H_DIG := 24
const BAND_H_SETTLE := 84
const AGG_TYPES := ["reward_ineffective"]
const COL_GOLD := UiKit.HEX_GOLD
const COL_INK := UiKit.HEX_INK
var _db
var _log: RichTextLabel
@@ -40,15 +39,13 @@ func _init(db_ref) -> void:
add_child(vb)
var head := HBoxContainer.new()
vb.add_child(head)
_summary = UiKit.label("", 16, false)
# 对比度修复(v3.5 批 B3 2026-09-30——明细条灰字对深灰底实测 2.63:1):暗底双色墨制=暖白族
_summary.add_theme_color_override("font_color", UiKit.WARM)
_summary = UiKit.label("", 16, false, UiKit.WARM)
_summary.custom_minimum_size = Vector2(0, BAND_H_DIG - 4)
_summary.clip_text = true
_summary.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
head.add_child(_summary)
_hint = UiKit.label("结算明细 ▸ 点击展开回看", 14, false)
_hint.add_theme_color_override("font_color", UiKit.WARM) # 辅助提示(≥14 辅助档)随上暖白
_hint = UiKit.label("结算明细 ▸ 点击展开回看", 14, false, UiKit.WARM) # 辅助提示(≥14 辅助档)随上暖白
_hint.size_flags_horizontal = Control.SIZE_SHRINK_END
head.add_child(_hint)
_log = RichTextLabel.new()
@@ -162,7 +159,7 @@ func _gold_frame(text: String) -> String:
func _format_row(row: Dictionary, agg_count := -1) -> String:
var src := _source_label(String(row.get("source_class", "")))
var round_no: int = int(row.get("round_index", 0))
var prefix := " [color=#9a8a72]R%d%s[/color] " % [round_no, ("·" + src) if src != "" else ""]
var prefix := " [color=#%s]R%d%s[/color] " % [UiKit.HEX_LOG_ROUND, round_no, ("·" + src) if src != "" else ""]
var hl := String(row.get("highlight", ""))
var v: int = int(row.get("value", 0))
var name_: String = String(row.get("display_name", ""))
@@ -174,12 +171,12 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
# GP 型效果行(GP+商店房实施批 2026-09-21):「金币袋 +3」兑付反馈——value = 本行 ±power,
# escrow = 战斗内缓冲现值(入钱包在战斗末一次上抛——呈现语义与增益池分账)
if String(row.get("effect_id", "")) in ["gp_gain", "gp_loss"]:
var gp_col := "#e0b860" if v > 0 else "#c47c7c"
var gp_col := UiKit.HEX_LOG_GAIN if v > 0 else UiKit.HEX_LOG_LOSS
var gp_body := "%s %+d(战斗暂存 %+d)%s" % [name_, v, int(row.get("escrow", 0)), suffix]
return prefix + (_gold_frame(gp_body) if hl != "" else "[color=%s]%s[/color]" % [gp_col, gp_body])
# 效果行 value = 单次贡献值,pool_value = 池合成值——禁相加呈现(战斗结算 呈现细则)
var pool: int = int(row.get("pool_value", 0))
var col := "#7cc47c" if v > 0 else "#c47c7c"
var col := UiKit.HEX_LOG_HEAL if v > 0 else UiKit.HEX_LOG_LOSS
var body := "%s 单次%+d(池 %+d)%s" % [name_, v, pool, suffix]
return prefix + (_gold_frame(body) if hl != "" else "[color=%s]%s[/color]" % [col, body])
"damage":
@@ -197,29 +194,29 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
var kill := " ⚔击杀!" if row.get("killed", false) else ""
# 二轮修改批 C(2026-09-29 用户目检——「每次结算标出的伤害数字不明显」):
# 伤害值放大加色独立于行文(朱红大一号——数字即信息主角,非同字号色温微差)
var body := "%s [color=#d9402a][size=17]-%d[/size][/color]%s%s%s%s" % [name_, v, roll_txt, tail, kill, suffix]
var body_col := "#e07050" if row.get("killed", false) else ("#9a8a72" if v == 0 else "#e0a050")
var body := "%s [color=#%s][size=17]-%d[/size][/color]%s%s%s%s" % [name_, UiKit.HEX_LOG_DMG_NUM, v, roll_txt, tail, kill, suffix]
var body_col := UiKit.HEX_LOG_DMG_KILL if row.get("killed", false) else (UiKit.HEX_LOG_ROUND if v == 0 else UiKit.HEX_LOG_DMG_BODY)
if bool(row.get("crit", false)):
return prefix + _gold_frame(body) # nat 最大面暴击 = 金框高亮行
return prefix + "[color=%s]%s[/color]" % [body_col, body]
"heal":
return prefix + "[color=#7cc47c]%s +%d%s%s[/color]" % [name_, v, tail, suffix]
return prefix + "[color=#%s]%s +%d%s%s[/color]" % [UiKit.HEX_LOG_HEAL, name_, v, tail, suffix]
"reward_ineffective":
# 未生效类行:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数
var n := (" ×%d" % agg_count) if agg_count > 1 else ""
return prefix + "[s][color=#b08060]⚠ %s%s[/color][/s]%s" % [name_, n, suffix]
return prefix + "[s][color=#%s]⚠ %s%s[/color][/s]%s" % [UiKit.HEX_LOG_VOIDED, name_, n, suffix]
"flag_miss":
var body := "✗ %s%s" % [name_, "(本板完美排雷已破——归因此旗)" if hl == "perfect_broken" else ""]
return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#9a8a72]%s[/color]" % body)
return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#%s]%s[/color]" % [UiKit.HEX_LOG_ROUND, body])
"cast":
# 施放行(敌人技能池批——蓄力行 charge 改型):增益技 0 伤害施放,具名技能
return prefix + "[color=#b09a72]✦ %s%s[/color]" % [name_, suffix]
return prefix + "[color=#%s]✦ %s%s[/color]" % [UiKit.HEX_LOG_CAST, name_, suffix]
"decay":
return prefix + "[color=#8a9ab0]状态衰减 ×%d[/color]" % v
return prefix + "[color=#%s]状态衰减 ×%d[/color]" % [UiKit.HEX_LOG_DECAY, v]
"verdict":
if int(row.value) == 1:
return "[color=#80d080][b]◆ %s[/b][/color]" % name_
return "[color=#e0b860][b]◆ %s[/b][/color]" % name_
return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_WIN, name_]
return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_GAIN, name_]
"highlight":
return prefix + _gold_frame("★ %s" % name_)
return prefix + name_ + suffix
+25 -29
View File
@@ -25,6 +25,7 @@ const _STANDEE_BODY := preload("res://presentation/views/standee_body.gd")
const _CONTENT_MARGINS := Rect2(6, 4, 6, 4) # 原 PanelContainer stylebox 内容边距(子面几何零漂移)
const _STANDEE_W := 150.0 # 站桩盒宽(旧 StandeeView custom_minimum_size)
const _STANDEE_GAP := 8.0 # 敌站桩间距(旧 enemy_box HBox separation)
const STAGE_CENTER_HINT := "兑现演出区\n(结算期:光带 → 弹道 → 受击)" # 演出区占位文案(set_settle_focus 恢复态同源——字面量双写退役)
var hero_standee: Node2D # StandeeBody——主角站桩本体(世界内;slot_row = 壳层伴随件引用)
var round_label: Label
@@ -42,17 +43,18 @@ var world: Node2D # StageWorld(preload 脚本实例——Node2D 世
func _init(db_ref) -> void:
custom_minimum_size = Vector2(0, 252)
# 面板底(圣经 v3.5 批 B4 2026-09-30——§6.1 舞台正面目标落码:B 区=地窟看台,世界底层暗背景
# Color8(62,52,42)=方向稿 render_battle_screen 取值——替换原浅石板皮〔实测 L0.027 旧深底与
# 上半米黄割裂的 opposite 端——v3.5 定暖暗中档〕;描边=PANEL_BORDER 同方向稿)
# =方向稿 render_battle_screen 取值(值单点收 UiKit.STAGE_BG——T4 裸色收口)——替换原浅石板皮
# 〔实测 L0.027 旧深底与上半米黄割裂的 opposite 端——v3.5 定暖暗中档〕;描边=PANEL_BORDER 同方向稿)
var bg := Panel.new()
var sb := UiKit.panel_flat(Color8(62, 52, 42), UiKit.PANEL_BORDER, 0, 1)
var sb := UiKit.panel_flat(UiKit.STAGE_BG, UiKit.PANEL_BORDER, 0, 1)
bg.add_theme_stylebox_override("panel", sb)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
# 顶光柱氛围层(§6.1 光源氛围——方向稿四柱:x 基准 110/900/1055/1210@1280,暖光 (255,236,200)
# 自顶向下 60 段渐宽窄渐隐〔顶 α26→0〕;绘制在暗底之上/世界站桩之下——层序=bg < 光柱 < 世界)
# 顶光柱氛围层(§6.1 光源氛围——方向稿四柱:x 基准 110/900/1055/1210@1280,暖光(值单点收
# UiKit.BEAM_LIGHT——T4 裸色收口)自顶向下 60 段渐宽窄渐隐〔顶 α26→0〕;绘制在暗底之上/
# 世界站桩之下——层序=bg < 光柱 < 世界)
var beams := Control.new()
beams.set_anchors_preset(Control.PRESET_FULL_RECT)
beams.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -86,9 +88,8 @@ func _init(db_ref) -> void:
vb.offset_bottom = -_CONTENT_MARGINS.size.y
add_child(vb)
round_label = UiKit.label("第 — 轮 · —", 15, true)
round_label = UiKit.label("第 — 轮 · —", 15, true, UiKit.WARM) # 暗底双色墨制(v3.5 批 B4——原深墨对暗底不可读)
round_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
round_label.add_theme_color_override("font_color", UiKit.WARM) # 暗底双色墨制(v3.5 批 B4——原深墨对暗底不可读)
vb.add_child(round_label)
_row = HBoxContainer.new()
@@ -103,8 +104,7 @@ func _init(db_ref) -> void:
var center_col := VBoxContainer.new()
center_col.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_child(center_col)
stage_center = UiKit.label("兑现演出区\n(结算期:光带 → 弹道 → 受击)", 13, false)
stage_center.add_theme_color_override("font_color", UiKit.WARM_SOFT) # 暗底双色墨制(原 STONE_FAINT 对暗底不可读)
stage_center = UiKit.label(STAGE_CENTER_HINT, 13, false, UiKit.WARM_SOFT) # 暗底双色墨制(原 STONE_FAINT 对暗底不可读)
stage_center.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
center_col.add_child(stage_center)
_row.add_child(center)
@@ -146,7 +146,7 @@ func _draw_light_beams(c: Control) -> void:
var a := int(26.0 * (1.0 - float(i) / 60.0))
var half := 46.0 - float(i) / 3.0
c.draw_rect(Rect2(x0 - half, 0.0, half * 2.0, 40.0 + float(i) * 3.0),
Color8(255, 236, 200, a), true)
Color(UiKit.BEAM_LIGHT, float(a) / 255.0), true)
func _make_slot_companion(parent: Control) -> StatusSlotRow:
@@ -314,31 +314,27 @@ func on_step_row_fx(row: Dictionary) -> void:
# 主角受击演出(迭代 6 T5 拆早退——旧「敌→主角零反馈」):弹道到达拍对齐,
# 受击态+闪白+打击火花+击杀塌陷与敌方路径同密度(演出对等——计划书 §1.4)
hero_standee.set_hero_state("hit") # 受击态(短驻自回 idle)
var hero_arrived := func():
hero_standee.play_hit_impact()
hero_standee.flash_hit()
if bool(row.get("killed", false)):
hero_standee.mark_dead()
if get_tree() != null:
get_tree().create_timer(UiKit.motion(0.33)).timeout.connect(hero_arrived)
else:
hero_arrived.call()
_schedule_arrival_fx(hero_standee, bool(row.get("killed", false)))
return
hero_standee.set_hero_state("attack") # 我方攻击行——attack 态(短驻自回)
var card: Node2D = _card_by_name(target)
if card == null:
return
# 弹道到达拍对齐(迭代 6 T4 终稿):闪白/打击火花/击杀塌陷延迟 ≈弹道飞行时长
# (play_projectile 0.16+0.19)——呈现链序=弹道→闪白+火花→血条(投影刷新)→灰化塌陷。
# 行突发同拍多定时器互不干扰(flash 重启语义/死亡幂等守卫);卡片提前释放=守卫跳过;
# 树外(防御)=即时执行不调度。
_schedule_arrival_fx(card, bool(row.get("killed", false)))
# 弹道到达拍调度单点(主角受击/敌方受击两份「0.33s 定时器+到达 lambda」同构块合并——T4 杂并批):
# 闪白/打击火花/击杀塌陷延迟 ≈弹道飞行时长(play_projectile 0.16+0.19)——呈现链序=弹道→闪白+火花
# →血条(投影刷新)→灰化塌陷。行突发同拍多定时器互不干扰(flash 重启语义/死亡幂等守卫);
# 本体提前释放=is_instance_valid 守卫跳过;树外(防御)=即时执行不调度。
func _schedule_arrival_fx(body: Node2D, killed: bool) -> void:
var arrived := func():
if not is_instance_valid(card):
if not is_instance_valid(body):
return
card.play_hit_impact()
card.flash_hit()
if bool(row.get("killed", false)):
card.mark_dead()
body.play_hit_impact()
body.flash_hit()
if killed:
body.mark_dead()
if get_tree() != null:
get_tree().create_timer(UiKit.motion(0.33)).timeout.connect(arrived)
else:
@@ -381,4 +377,4 @@ func settle_hero_idle() -> void:
# 结算期 B 区第一注视点切换(能量 7/10 清算调性):结算中占位文案退位——兑现演出由
# SettlementFxView 弹道/光带本体接管(演出链批);挖掘期恢复教学占位文案
func set_settle_focus(on: bool) -> void:
stage_center.text = "" if on else "兑现演出区\n(结算期:光带 → 弹道 → 受击)"
stage_center.text = "" if on else STAGE_CENTER_HINT
+1 -2
View File
@@ -67,7 +67,6 @@ var bevel := 0.0: # 顶面倒角强度(0=关——立体原
const FACADE_HIGHLIGHT_PX := 2.0 # 口沿高光线厚(像素)
const FACADE_NEAR_FRAC := 0.16 # 立面近段高(格边比——研究件 10/64)
const FACADE_FAR_FRAC := 0.25 # 立面远段高(格边比——研究件 16/64)
const FACADE_HIGHLIGHT_COL := Color8(196, 172, 132) # 口沿高光(研究件取色暖米白)
const FACADE_NEAR_COL := Color(0, 0, 0, 0.60) # 立面近段黑罩(墙顶 132→52 实测 α≈0.61)
const FACADE_FAR_COL := Color(0, 0, 0, 0.82) # 立面远段黑罩(墙顶 132→24 实测 α≈0.82)
const PROJECTION_NEAR_COL := Color(0, 0, 0, 0.28) # 投影贴墙段(地面 66→46 实测 α≈0.28)
@@ -251,7 +250,7 @@ func _draw_south_facade() -> void:
var y_far := edge - far_h
var y_near := y_far - near_h
var y_hl := y_near - FACADE_HIGHLIGHT_PX
draw_rect(Rect2(0.0, y_hl, edge, FACADE_HIGHLIGHT_PX), FACADE_HIGHLIGHT_COL, true)
draw_rect(Rect2(0.0, y_hl, edge, FACADE_HIGHLIGHT_PX), UiKit.STAND_STONE_RIM, true) # 口沿高光(UiKit.STAND_STONE_RIM 并档——同值单点,清扫批 2026-10-02)
draw_rect(Rect2(0.0, y_near, edge, near_h), FACADE_NEAR_COL, true)
draw_rect(Rect2(0.0, y_far, edge, far_h), FACADE_FAR_COL, true)
+1 -2
View File
@@ -17,8 +17,7 @@ var _banner: Label
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT)
_banner = UiKit.label("", 26, true)
_banner.add_theme_color_override("font_color", COL_GOLD)
_banner = UiKit.label("", 26, true, COL_GOLD)
_banner.set_anchors_preset(Control.PRESET_CENTER_TOP)
_banner.grow_horizontal = Control.GROW_DIRECTION_BOTH
_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+8 -22
View File
@@ -35,7 +35,6 @@ const COL_DIGIT := UiKit.INK # 数字——墨族 #33261B(对
const COL_HERO := UiKit.HERO_BLUE # 小人边框——小人蓝 #2E6E8E
const COL_FLAG := UiKit.SEAL_BLACK # 封印黑 #000000(旗朱→封印紫→封印黑,2026-09-28 用户拍板)
const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
const COL_PATH := UiKit.HERO_BLUE # 路径预览点迹——小人蓝细线
const COL_CONTACT := UiKit.CONTACT_GLOW # 接触带微光基色(蓝族浅一档·近白蓝——§3.2 v2.8;旧绿相退役 2026-09-26)
const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金)
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键;极性视觉归批 2)
@@ -269,8 +268,6 @@ func rebuild(board: BoardData, minesweeper) -> void:
_hover = Vector2i(-9, -9)
for c in _board.compute_r0():
_r0[c] = true
if _board == null:
return
# 瓦片套随板图型切换(T6 换色批——board_gen 词表 v1.1 dungeon/underdark/deep_underdark;
# 同图型重复 rebuild 零重载,_tiles_board_type 守卫)
var bt: String = _board.board_type if _board.board_type != "" else "dungeon"
@@ -743,7 +740,9 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
u.proj_band = 0.0
u.tex_facade = null
u.facade_strip = false
u.tex_flag = null
# 旗槽不在此重置(2026-10-02 修复批——T13 批的 paint 尾部 `u.tex_flag = null` 与 L683 的条件
# 赋值构成双写踩踏:执行序 683→此处把旗纹理恒清 null=素材在位时插旗不可见;L683 的 else 分支
# 已是全路径清残留,尾部重置系冗余且有害,删除)
var south_n: Vector2i = c + Vector2i(0, 1)
var north_n: Vector2i = c + Vector2i(0, -1)
var south_in: bool = south_n.x >= 0 and south_n.y >= 0 and south_n.x < _board.width and south_n.y < _board.height
@@ -856,24 +855,11 @@ func _reset_breath() -> void:
func _load_board_tiles(board_type: String = "dungeon") -> void:
if board_type == "":
board_type = "dungeon"
var t := {
"wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]),
"wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]),
"wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]),
"wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]),
"wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]),
"wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]),
"wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]),
"wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]),
"wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]),
"wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]),
"wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]),
"wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]),
"wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]),
"wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]),
"wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]),
"wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]),
}
# 16 墙瓦片装载循环节(词表 TILE_WALL_NAMES 单点——T4 杂并:手写 16 条字典项重复退役;
# 词表本身被 terrain_tile_sets_test 直读,保留)
var t := {}
for wall_name in TILE_WALL_NAMES:
t[wall_name] = UiKit.load_tex(TILE_WALL_FMT % [wall_name, board_type])
var floors: Array[Texture2D] = []
for i in range(1, TILE_FLOOR_VARIANTS + 1):
floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i]))
+1 -1
View File
@@ -76,7 +76,7 @@ var cols := 0: # 列数(正面悬崖竖缝数 = cols−1)
set(v):
cols = v
queue_redraw()
var mat := Color8(201, 175, 131) # 材质锚色(掩尘 #C9AF83——侧面由此派生明暗;BoardView 传入)
var mat: Color = UiKit.BRICK # 材质锚色(掩尘 #C9AF83 与 UiKit.BRICK 同值——T4 裸色收口改引单点;侧面由此派生明暗,BoardView 传入可覆写)
func _draw() -> void:
+14 -16
View File
@@ -22,18 +22,8 @@ var _card_meta: Array = [] # open_pick 缓存 [{id, text}]—
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
center.add_child(box)
_label = UiKit.label("", 20, true)
_label.add_theme_color_override("font_color", UiKit.WARM)
var box: VBoxContainer = UiKit.modal_shell(self, Color(0, 0, 0, 0.6)).box
_label = UiKit.label("", 20, true, UiKit.WARM)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_label)
_choice_box = HBoxContainer.new()
@@ -49,7 +39,7 @@ func _init() -> void:
_replace_box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_child(_replace_box)
_continue_btn = UiKit.button("继续")
_continue_btn.pressed.connect(func(): continue_pressed.emit())
_continue_btn.pressed.connect(_emit_continue_pressed)
_continue_btn.visible = false
box.add_child(_continue_btn)
visible = false
@@ -68,7 +58,7 @@ func open_pick(header: String, cards: Array) -> void:
var cid := String(ch.get("id", ""))
var btn := UiKit.button(String(ch.get("text", "")))
btn.custom_minimum_size = Vector2(170, 88)
btn.pressed.connect(func(): _select_card(cid))
btn.pressed.connect(_select_card.bind(cid)) # 具名方法 bind(仓内惯例——内联 lambda 退役)
_choice_box.add_child(btn)
_choice_box.visible = true
_confirm_btn.visible = false
@@ -129,10 +119,10 @@ func open_replace(items: Array, incoming_name: String, holding: int, slots: int)
for it in items:
var tag: int = int(it.get("index", it.get("handle", -1))) # 通关渠道=携带序号 / 节点渠道=库存句柄(核心分发面裁决)
var btn := UiKit.button("丢弃「%s」换新" % String(it.get("display_name", it.get("item_id", ""))))
btn.pressed.connect(func(): replace_chosen.emit(tag))
btn.pressed.connect(_emit_replace_chosen.bind(tag))
_replace_box.add_child(btn)
var decline := UiKit.button("放弃新件(不获取)")
decline.pressed.connect(func(): replace_chosen.emit(-1))
decline.pressed.connect(_emit_replace_chosen.bind(-1))
_replace_box.add_child(decline)
_choice_box.visible = false
_confirm_btn.visible = false
@@ -148,3 +138,11 @@ func close_pick() -> void:
_continue_btn.visible = false
_confirm_btn.visible = false
visible = false
func _emit_continue_pressed() -> void:
continue_pressed.emit()
func _emit_replace_chosen(tag: int) -> void:
replace_chosen.emit(tag)
+19 -34
View File
@@ -15,10 +15,10 @@ signal buy_requested(attr: String)
signal closed
const ATTR_LABELS := {"atk": "攻击", "max_hp": "生命上限", "armor_class": "护甲"} # 档位文案轴(数据契约 attr 词表,与 ShopOverlay 同款)
# 方向稿取色(4-camp.png——柜台头带深棕/行板暖米;双色墨=亮板配深墨)
const HEADER_BAND_COL := Color8(122, 82, 50)
const ROW_PLATE_COL := Color8(240, 230, 206)
const BADGE_PANEL_COL := Color8(40, 32, 24)
# 方向稿取色(4-camp.png——柜台头带深棕/行板暖米;双色墨=亮板配深墨)——值单点收 UiKit(T4 裸色收口批)
const HEADER_BAND_COL := UiKit.CAMP_HEADER_BAND
const ROW_PLATE_COL := UiKit.CAMP_ROW_PLATE
const BADGE_PANEL_COL := UiKit.CAMP_BADGE_PANEL
var _balance_label: Label
var _forge_holder: VBoxContainer
@@ -29,10 +29,7 @@ var _recent_list: Label # 最近冒险(逐行拼接 label——只读
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.45) # 方向稿=石纹底透出(浅 dim——石纹全屏底担当氛围)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var dim := UiKit.modal_dim(self, Color(0, 0, 0, 0.45)) # 方向稿=石纹底透出(浅 dim——石纹全屏底担当氛围)
# 石纹底噪(样张 v2 冻结件——start overlay 同款;缺位不挂即回退平 dim)
var bd_tex := UiKit.load_tex(UiKit.TEX_BACKDROP_STONE)
if bd_tex != null:
@@ -65,11 +62,9 @@ func _init() -> void:
top.add_child(plaque)
var plaque_col := VBoxContainer.new()
plaque.add_child(plaque_col)
var title := UiKit.label("营地 · 永久强化", 22, true)
title.add_theme_color_override("font_color", UiKit.INK_TITLE)
var title := UiKit.label("营地 · 永久强化", 22, true, UiKit.INK_TITLE)
plaque_col.add_child(title)
var sub := UiKit.label("跨局成长账本——钥石换永久属性", 13, false)
sub.add_theme_color_override("font_color", UiKit.STONE)
var sub := UiKit.label("跨局成长账本——钥石换永久属性", 13, false, UiKit.STONE)
plaque_col.add_child(sub)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -84,8 +79,7 @@ func _init() -> void:
var keystone_icon := UiKit.icon_rect(UiKit.TEX_ICON_KEYSTONE, 18) # 缺位 null → 纯文本回退
if keystone_icon != null:
bal_row.add_child(keystone_icon)
_balance_label = UiKit.label("钥石 0", 18, true)
_balance_label.add_theme_color_override("font_color", UiKit.GOLD)
_balance_label = UiKit.label("钥石 0", 18, true, UiKit.GOLD)
bal_row.add_child(_balance_label)
# ── 三柜台列(方向稿同构:头带深棕+米色板身)──
@@ -102,8 +96,7 @@ func _init() -> void:
mid.add_child(_counter_panel("道具工坊", UiKit.TEX_ICON_WORKSHOP, _workshop_holder, 356))
var atlas_holder := VBoxContainer.new()
mid.add_child(_counter_panel("奖励格图鉴", UiKit.TEX_ICON_ATLAS, atlas_holder, 356))
var lock_l := UiKit.label(" —— 柜台结构位预留:内容排 Alpha(地图战斗资源差异化批)", 13, false)
lock_l.add_theme_color_override("font_color", UiKit.STONE)
var lock_l := UiKit.label(" —— 柜台结构位预留:内容排 Alpha(地图战斗资源差异化批)", 13, false, UiKit.STONE)
lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lock_l.custom_minimum_size = Vector2(300, 0)
atlas_holder.add_child(lock_l)
@@ -117,12 +110,10 @@ func _init() -> void:
var spacer2 := Control.new()
spacer2.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(spacer2)
var caption := UiKit.label("每购得一件永久强化,对应柜台亮起一格暖光 · 价格为钥石", 13, false)
caption.add_theme_color_override("font_color", UiKit.WARM_SOFT)
var caption := UiKit.label("每购得一件永久强化,对应柜台亮起一格暖光 · 价格为钥石", 13, false, UiKit.WARM_SOFT)
bottom.add_child(caption)
col.add_child(bottom)
_recent_list = UiKit.label("", 13, false)
_recent_list.add_theme_color_override("font_color", UiKit.WARM_SOFT)
_recent_list = UiKit.label("", 13, false, UiKit.WARM_SOFT)
_recent_list.clip_text = true
_recent_list.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
col.add_child(_recent_list)
@@ -148,8 +139,7 @@ func _counter_panel(title_text: String, icon_path: String, holder: VBoxContainer
var ic := UiKit.icon_rect(icon_path, 16) # 缺位 null → 纯文本回退
if ic != null:
head.add_child(ic)
var l := UiKit.label(title_text, 16, true)
l.add_theme_color_override("font_color", UiKit.WARM)
var l := UiKit.label(title_text, 16, true, UiKit.WARM)
head.add_child(l)
vbox.add_child(holder)
return panel
@@ -223,16 +213,13 @@ func _build_forge_row(holder: VBoxContainer, row: Dictionary) -> Dictionary:
line.add_theme_constant_override("separation", 12)
card.add_child(line)
var attr := String(row.get("attr", ""))
var name_l := UiKit.label("%s %+d/级" % [String(row.get("display_name", "")), int(row.get("flat", 0))], 14, true)
name_l.add_theme_color_override("font_color", UiKit.INK)
var name_l := UiKit.label("%s %+d/级" % [String(row.get("display_name", "")), int(row.get("flat", 0))], 14, true, UiKit.INK)
name_l.custom_minimum_size = Vector2(132, 0)
line.add_child(name_l)
var level_l := UiKit.label("", 14, false)
level_l.add_theme_color_override("font_color", UiKit.INK)
var level_l := UiKit.label("", 14, false, UiKit.INK)
level_l.custom_minimum_size = Vector2(44, 0)
line.add_child(level_l)
var price_l := UiKit.label("", 14, false)
price_l.add_theme_color_override("font_color", UiKit.STONE)
var price_l := UiKit.label("", 14, false, UiKit.STONE)
price_l.custom_minimum_size = Vector2(64, 0)
line.add_child(price_l)
var btn := UiKit.button("锻造 +1 级")
@@ -252,16 +239,14 @@ func _rebuild_workshop(data: Dictionary) -> void:
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 12)
card.add_child(line)
var name_l := UiKit.label(String(row.get("display_name", "")), 14, true)
name_l.add_theme_color_override("font_color", UiKit.INK)
var name_l := UiKit.label(String(row.get("display_name", "")), 14, true, UiKit.INK)
name_l.custom_minimum_size = Vector2(132, 0)
line.add_child(name_l)
var progress_l := UiKit.label("", 13, false)
progress_l.add_theme_color_override("font_color", UiKit.STONE)
var progress_l := UiKit.label("", 13, false, UiKit.STONE)
progress_l.custom_minimum_size = Vector2(110, 0)
line.add_child(progress_l)
var status_l := UiKit.label("", 13, true)
status_l.add_theme_color_override("font_color", UiKit.STONE)
# 状态色随卡⑭ 亮板双色墨制(深墨族替代暖白族)——色随刷新态条件赋值,非机械两行式(T4 收口保留)
var status_l := UiKit.label("", 13, true, UiKit.STONE)
line.add_child(status_l)
_workshop_holder.add_child(card)
_workshop_rows.append({"item_id": String(row.get("item_id", "")), "progress_l": progress_l, "status_l": status_l})
+6 -22
View File
@@ -15,22 +15,9 @@ var _cancel_btn: Button
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(UiKit.DIM, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", UiKit.panel_skinned(Vector4(14, 12, 14, 12)))
center.add_child(panel)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(460, 0)
box.add_theme_constant_override("separation", 12)
panel.add_child(box)
_text = UiKit.label("", 16, false)
_text.add_theme_color_override("font_color", UiKit.INK_TITLE)
var box: VBoxContainer = UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(460, 0), 12,
true, UiKit.panel_skinned(Vector4(14, 12, 14, 12))).box
_text = UiKit.label("", 16, false, UiKit.INK_TITLE)
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_text.custom_minimum_size = Vector2(430, 0)
box.add_child(_text)
@@ -52,12 +39,9 @@ func open(text: String, confirm_label: String, cancel_label: String = "取消")
_text.text = text
_confirm_btn.text = confirm_label
_cancel_btn.text = cancel_label
# z 序自提(三轮修改批 #1——用户实机实证:本弹窗构建序早于开始覆盖层,而开始层是组合根
# 尾部才 add_child 的(树序最末=绘制最上),弹窗被整层盖住——「看不见弹窗/离开菜单才显形」。
# 每次打开强制提到宿主子级最末,绘制序恒最上。)
var host := get_parent()
if host != null:
host.move_child(self, host.get_child_count() - 1)
# z 序纪律(T3 收口批 2026-10-02):树序由组合根构建序保证——本弹窗在 main._build_ui 尾部
# add_child(开始层/过场层/诊断钮之后 = 子级最末,登记序 = 树序 = 绘制序恒最上),
# 「每次打开 move_child 运行时自提」创可贴随之删除。
visible = true
@@ -94,18 +94,11 @@ func set_level_bonuses(lines: Array) -> void:
_bonus_list.remove_child(c)
c.queue_free()
for line in lines:
var l := UiKit.label(String(line), 16, false) # 字号下限(v3.5 批 B3——正文 ≥16)
l.add_theme_color_override("font_color", COL_INK)
var l := UiKit.label(String(line), 16, false, COL_INK) # 字号下限(v3.5 批 B3——正文 ≥16)
_bonus_list.add_child(l)
_bonus_section.visible = not lines.is_empty()
func set_idle() -> void:
_atk_label.text = "攻击 —"
_armor_label.text = "护甲 AC —"
set_level_bonuses([])
func attack_text() -> String:
return _atk_label.text
@@ -115,10 +108,9 @@ func armor_text() -> String:
func _data_line() -> Label:
var l := UiKit.label("", 16, false)
# 对比度修复(v3.5 批 B3 2026-09-30——非粗体 label 默认浅灰对米色数据框仅 1.31:1,
# 实测=ui-redesign 对比报告 §一):显式主墨(对 PANEL_DATA ≈10:1)
l.add_theme_color_override("font_color", UiKit.INK)
var l := UiKit.label("", 16, false, UiKit.INK)
l.clip_text = true
l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
return l
@@ -150,6 +142,7 @@ func set_aux_inventory(items: Array, selected_handle: int) -> void:
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
glyph.set_anchors_preset(Control.PRESET_FULL_RECT)
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
# 墨色随 used/usable 条件赋值——非机械两行式,T4 收口保留原分支
if used:
glyph.add_theme_color_override("font_color", UiKit.STONE_FAINT)
elif not usable:
+10 -30
View File
@@ -3,7 +3,7 @@
# 状态槽空态说明成文载入〔增益状态 D1×①〕)。
# Date: 2026-09-12
# pattern 6 模态 overlay 层级纪律:打开时由组合根隐藏上方遮挡层 + 锁棋盘输入;
# 本类自持开关(toggle),探针断言"打开后自身 visible"——遮挡物断言归场景探针。
# 本类自持开关(open/close),探针断言"打开后自身 visible"——遮挡物断言归场景探针。
class_name LegendOverlay
extends Control
@@ -27,23 +27,12 @@ var _rap_cost := -1 # <0 = 不渲染 RAP 行(打磨包 2026-09-29——构
func _init(rap_cost: int = -1) -> void:
_rap_cost = rap_cost
var dim := ColorRect.new()
dim.color = Color(UiKit.DIM, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.gui_input.connect(_on_dim_gui_input)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
_panel = PanelContainer.new()
_panel.add_theme_stylebox_override("panel", UiKit.panel_skinned(Vector4(12, 10, 12, 10))) # 石板皮(T2;缺位回退 flat)
center.add_child(_panel)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(520, 0)
box.add_theme_constant_override("separation", 6)
_panel.add_child(box)
var title := UiKit.label("图例 · 格态", 20, true)
title.add_theme_color_override("font_color", UiKit.INK_TITLE) # 亮底墨字(§7.2 数据素面)
var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(520, 0), 6, true,
UiKit.panel_skinned(Vector4(12, 10, 12, 10))) # 石板皮(T2;缺位回退 flat)
_panel = shell.panel
(shell.dim as ColorRect).gui_input.connect(_on_dim_gui_input)
var box: VBoxContainer = shell.box
var title := UiKit.label("图例 · 格态", 20, true, UiKit.INK_TITLE) # 亮底墨字(§7.2 数据素面)
box.add_child(title)
box.add_child(HSeparator.new())
for e in ENTRIES:
@@ -53,8 +42,7 @@ func _init(rap_cost: int = -1) -> void:
chip.custom_minimum_size = Vector2(18, 18)
chip.color = Color(e[0])
row.add_child(chip)
var lab := UiKit.label("%s —— %s" % [e[1], e[2]], 13, false)
lab.add_theme_color_override("font_color", UiKit.INK_LABEL) # 亮底墨字(§7.2 数据素面)
var lab := UiKit.label("%s —— %s" % [e[1], e[2]], 13, false, UiKit.INK_LABEL) # 亮底墨字(§7.2 数据素面)
lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lab.custom_minimum_size = Vector2(440, 0)
row.add_child(lab)
@@ -66,10 +54,9 @@ func _init(rap_cost: int = -1) -> void:
rap_row.add_theme_constant_override("separation", 8)
var rap_chip := ColorRect.new()
rap_chip.custom_minimum_size = Vector2(18, 18)
rap_chip.color = Color("#F0A828")
rap_chip.color = UiKit.GOLD # 同 #F0A828——金徽章同源单点(T4 裸色收口)
rap_row.add_child(rap_chip)
var rap_lab := UiKit.label("RAP 储备行动点 —— 停手时剩余 AP 转存;弃图刷新消耗 RAP−%d(旧板旗作废,AP 保留继续挖)" % _rap_cost, 13, false)
rap_lab.add_theme_color_override("font_color", UiKit.INK_LABEL)
var rap_lab := UiKit.label("RAP 储备行动点 —— 停手时剩余 AP 转存;弃图刷新消耗 RAP−%d(旧板旗作废,AP 保留继续挖)" % _rap_cost, 13, false, UiKit.INK_LABEL)
rap_lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
rap_lab.custom_minimum_size = Vector2(440, 0)
rap_row.add_child(rap_lab)
@@ -85,13 +72,6 @@ func is_open() -> bool:
return visible
func toggle() -> void:
if visible:
close()
else:
open()
func open() -> void:
visible = true
+21 -21
View File
@@ -38,9 +38,13 @@ const TITLE_ENTRY := "出发——选择首个节点" # 入口停点
const TITLE_SINGLE := "继续前进" # 单链停点
const TITLE_FORK := "选择下一节点" # 岔路停点
const NODE_HALF := Vector2(26, 18) # 节点方块半宽高(52×36 占位画布参数——样张批终稿)
const LAYER_BASE_X := 70 # 首层节点原点 x(画布拟合与布局公式共用——T4 杂并:双写退役)
const LAYER_STEP_X := 122 # 层间横向步距(同上)
const SLOT_BASE_Y := 52 # 槽位原点 y(同上)
const SLOT_STEP_Y := 58 # 同层槽位纵向步距(同上)
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
const COL_HP_BAR_BACK := Color8(40, 34, 28) # 迷你血条深底(样张 v1 冻结——对暗面板可辨)
const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(样张 v1 冻结——对暗面板可辨;值单点收 UiKit)
var _runner = null
var _title: Label
@@ -92,8 +96,7 @@ func _init() -> void:
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
box.add_child(top)
_level_label = UiKit.label("", 16, true)
_level_label.add_theme_color_override("font_color", UiKit.WARM)
_level_label = UiKit.label("", 16, true, UiKit.WARM)
_level_label.custom_minimum_size.x = 420
top.add_child(_level_label)
var gp_box := PanelContainer.new()
@@ -102,11 +105,9 @@ func _init() -> void:
var gp_row := HBoxContainer.new()
gp_row.add_theme_constant_override("separation", 6)
gp_box.add_child(gp_row)
_gp_value = UiKit.label("", 16, true)
_gp_value.add_theme_color_override("font_color", UiKit.GOLD)
_gp_value = UiKit.label("", 16, true, UiKit.GOLD)
gp_row.add_child(_gp_value)
_title = UiKit.label(TITLE_FORK, 18, true)
_title.add_theme_color_override("font_color", UiKit.WARM)
_title = UiKit.label(TITLE_FORK, 18, true, UiKit.WARM)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title)
# 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
@@ -130,11 +131,9 @@ func _init() -> void:
_bonus_row.add_child(bonus_box)
var bonus_inner := VBoxContainer.new()
bonus_box.add_child(bonus_inner)
var bonus_cap := UiKit.label("本关加成", 12, false)
bonus_cap.add_theme_color_override("font_color", UiKit.WARM_SOFT)
var bonus_cap := UiKit.label("本关加成", 12, false, UiKit.WARM_SOFT)
bonus_inner.add_child(bonus_cap)
_bonus_value = UiKit.label("", 15, true)
_bonus_value.add_theme_color_override("font_color", UiKit.WARM)
_bonus_value = UiKit.label("", 15, true, UiKit.WARM)
bonus_inner.add_child(_bonus_value)
bottom.add_child(_bonus_row)
var spacer := Control.new()
@@ -148,25 +147,24 @@ func _init() -> void:
visible = false
# 状态数据框工厂(直角细线数据框——pattern 5 族;hp_box = 带迷你血条。墨色按样张 v1 冻结:
# 暗底标签 WARM_SOFT/读数 WARM——§7.1 数据素面墨来自世界的墨在暗底侧的落法,L3 ≥4.5:1)
# 状态数据框工厂(直角细线数据框——pattern 5 族;with_bar = 带迷你血条(生命框),值读数随行挂回
# _hp_value/_attr_value(T4 杂并:旧「caption == "生命"」文案做控制流退役——分组判据复用 with_bar)。
# 墨色按样张 v1 冻结:暗底标签 WARM_SOFT/读数 WARM——§7.1 数据素面墨来自世界的墨在暗底侧的落法,L3 ≥4.5:1)
func _status_box(caption: String, with_bar: bool) -> PanelContainer:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
var inner := VBoxContainer.new()
panel.add_child(inner)
var cap := UiKit.label(caption, 12, false)
cap.add_theme_color_override("font_color", UiKit.WARM_SOFT)
var cap := UiKit.label(caption, 12, false, UiKit.WARM_SOFT)
inner.add_child(cap)
if with_bar:
_hp_bar = Control.new()
_hp_bar.custom_minimum_size = Vector2(120, 6)
_hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar))
inner.add_child(_hp_bar)
var value := UiKit.label("", 15, true)
value.add_theme_color_override("font_color", UiKit.WARM)
var value := UiKit.label("", 15, true, UiKit.WARM)
inner.add_child(value)
if caption == "生命":
if with_bar:
_hp_value = value
else:
_attr_value = value
@@ -174,7 +172,7 @@ func _status_box(caption: String, with_bar: bool) -> PanelContainer:
func _draw_hp_bar(bar: Control) -> void:
# 迷你血条(样张 v1 冻结:深底 Color8(40,34,28)(对暗面板可辨)+金填充——比例 = hero_hp / effective_max_hp)
# 迷你血条(样张 v1 冻结:深底 UiKit.HP_BAR_BACK #28221C(对暗面板可辨)+金填充——比例 = hero_hp / effective_max_hp)
if _runner == null:
return
var max_hp := maxi(1, _runner.effective_max_hp())
@@ -268,7 +266,8 @@ func _fit_canvas(nodes: Array) -> void:
maxl = maxi(maxl, int(layer[k]))
for k in slot.keys():
maxslot = maxi(maxslot, int(slot[k]))
_content_size = Vector2(70 + maxl * 122 + NODE_HALF.x + 8.0, 52 + maxslot * 58 + NODE_HALF.y + 8.0)
_content_size = Vector2(LAYER_BASE_X + maxl * LAYER_STEP_X + NODE_HALF.x + 8.0,
SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 8.0)
_content_size = _content_size.max(Vector2(620, 240)) # 下限 = 旧定值画布(小图观感不变)
_canvas.custom_minimum_size = _content_size
@@ -453,7 +452,8 @@ func _layout(nodes: Array, canvas: Control) -> Dictionary:
var pos := {}
var origin := (canvas.size - _content_size) / 2.0
for node in nodes:
pos[node] = origin + Vector2(70 + int(layer[node]) * 122, 52 + int(slot[node]) * 58)
pos[node] = origin + Vector2(LAYER_BASE_X + int(layer[node]) * LAYER_STEP_X,
SLOT_BASE_Y + int(slot[node]) * SLOT_STEP_Y)
return pos
+2 -12
View File
@@ -12,18 +12,8 @@ var _btn: Button
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
center.add_child(box)
_label = UiKit.label("", 20, true)
_label.add_theme_color_override("font_color", UiKit.WARM)
var box: VBoxContainer = UiKit.modal_shell(self, Color(0, 0, 0, 0.6)).box
_label = UiKit.label("", 20, true, UiKit.WARM)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_label)
_btn = UiKit.button("继续")
+3 -15
View File
@@ -30,23 +30,11 @@ func retarget(store) -> void:
func _build() -> void:
var dim := ColorRect.new()
dim.color = UiKit.BG_OVERLAY
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(560, 0)
box.add_theme_constant_override("separation", 8)
center.add_child(box)
var box: VBoxContainer = UiKit.modal_shell(self, UiKit.BG_OVERLAY, Vector2(560, 0), 8).box
var title := UiKit.label("设置", 26, true)
title.add_theme_color_override("font_color", UiKit.WARM)
var title := UiKit.label("设置", 26, true, UiKit.WARM)
box.add_child(title)
var hint := UiKit.label("系统级设置——独立存储不随存档(一安装一份,删游戏才消失)", 13, false)
hint.add_theme_color_override("font_color", UiKit.WARM_SOFT)
var hint := UiKit.label("系统级设置——独立存储不随存档(一安装一份,删游戏才消失)", 13, false, UiKit.WARM_SOFT)
box.add_child(hint)
box.add_child(HSeparator.new())
@@ -47,8 +47,7 @@ func play_round_sweep(board_rect: Rect2) -> void:
# 跨区弹道:glyph 字形自 from 飞向 to(0.35s + 轻微上抛弧线 + 终点淡出,自动回收)——
# 开格之因 → 站桩之果的因果可视承载(P2);crit 弹道由调用方传加重标记
func play_projectile(from: Vector2, to: Vector2, glyph: String, col: Color, crit: bool = false) -> void:
var shot := UiKit.label(glyph, 22 if crit else 16, true)
shot.add_theme_color_override("font_color", col)
var shot := UiKit.label(glyph, 22 if crit else 16, true, col)
shot.mouse_filter = Control.MOUSE_FILTER_IGNORE
shot.size = Vector2(24, 24)
shot.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
@@ -92,8 +91,7 @@ func play_damage_number(pos: Vector2, text: String, col: Color, crit: bool = fal
_fx_damage_marks += 1
# T17 修复(用户实机「完全没看到伤害数字」):26/32px 大字+1.1s 驻留——旧 20px/0.7s 在
# 结算排播节奏下视觉存在感不足;描边 4→6 同步加重(亮舞台底上保读数)。
var lab := UiKit.label(text, 32 if crit else 26, true)
lab.add_theme_color_override("font_color", col)
var lab := UiKit.label(text, 32 if crit else 26, true, col)
lab.add_theme_color_override("font_outline_color", Color(0.1, 0.08, 0.06, 0.9))
lab.add_theme_constant_override("outline_size", 6)
lab.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -130,8 +128,7 @@ func play_round_banner(ri: int) -> void:
var banner := Panel.new()
banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
banner.add_theme_stylebox_override("panel", UiKit.panel_flat(Color(UiKit.BG_OVERLAY, 0.88), UiKit.GOLD_DEEP, 8, 2))
var lab := UiKit.label("第 %d 轮" % ri, 22, true)
lab.add_theme_color_override("font_color", UiKit.GOLD)
var lab := UiKit.label("第 %d 轮" % ri, 22, true, UiKit.GOLD)
lab.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
lab.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lab.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
@@ -11,20 +11,9 @@ var _log: RichTextLabel
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(UiKit.DIM, 0.62)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.gui_input.connect(_on_dim_gui_input)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var panel := PanelContainer.new()
center.add_child(panel)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(760, 560)
box.add_theme_constant_override("separation", 6)
panel.add_child(box)
var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.62), Vector2(760, 560), 6, true)
(shell.dim as ColorRect).gui_input.connect(_on_dim_gui_input)
var box: VBoxContainer = shell.box
box.add_child(UiKit.label("结算明细回看(完整流水 · 逐行归因)", 18, true))
_log = RichTextLabel.new()
_log.bbcode_enabled = true
+6 -19
View File
@@ -1,7 +1,7 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-21(GP+商店房实施批——商店房覆盖层呈现)
# 商店覆盖层(模态):标题「商店」/ 金币余额 / 三商品卡全陈列(显示名·档位文案·现价·已购档数)/
# 购买按钮(余额不足禁用置灰)/ 离开按钮。模态挂接方式仿既有覆盖层(route_overlay 现成参照——
# 购买按钮(余额不足禁用置灰)/ 离开按钮。模态挂接方式仿既有覆盖层(legend_overlay 现成参照——
# dim + CenterContainer + box,组合根 _register_modal 收口层级)。
# 高内聚边界:纯视图 setter 面——商品快照与余额经组合根喂入(runner.shop_products() 只读投影,
# 现价/已购档数由 runner 现算),购买/离开意图经信号外发——零核心写调用、零 runner 持有。
@@ -19,22 +19,11 @@ var _card_rows: Array[Dictionary] = [] # [{product_id, price, bought, btn}]—
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
center.add_child(box)
var title := UiKit.label("商店", 26, true)
title.add_theme_color_override("font_color", UiKit.WARM)
var box: VBoxContainer = UiKit.modal_shell(self, Color(0, 0, 0, 0.6)).box
var title := UiKit.label("商店", 26, true, UiKit.WARM)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title)
_gp_label = UiKit.label("金币 0", 18, true)
_gp_label.add_theme_color_override("font_color", UiKit.GOLD)
_gp_label = UiKit.label("金币 0", 18, true, UiKit.GOLD)
_gp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_gp_label)
_cards_box = VBoxContainer.new()
@@ -82,12 +71,10 @@ func _build_card(p: Dictionary) -> Dictionary:
var name_l := UiKit.label(_card_title(p), 15, true)
name_l.custom_minimum_size = Vector2(190, 0)
row.add_child(name_l)
var price_l := UiKit.label("", 14, false)
price_l.add_theme_color_override("font_color", UiKit.WARM_SOFT)
var price_l := UiKit.label("", 14, false, UiKit.WARM_SOFT)
price_l.custom_minimum_size = Vector2(110, 0)
row.add_child(price_l)
var bought_l := UiKit.label("", 14, false)
bought_l.add_theme_color_override("font_color", UiKit.WARM_FAINT)
var bought_l := UiKit.label("", 14, false, UiKit.WARM_FAINT)
row.add_child(bought_l)
var btn := UiKit.button("购买")
btn.pressed.connect(_emit_buy_requested.bind(p))
+6 -12
View File
@@ -124,11 +124,6 @@ func _set_hp(hp: int) -> void:
_hp_tween.tween_property(self, "hp_value", target, UiKit.motion(0.15))
func set_idle() -> void:
_set_hp(0)
set_effects([])
# 主角/敌方共用——状态效果唯一展示面(壳层伴随件转发;空态 = 图标行隐藏不占位)
func set_effects(effects: Array) -> void:
if slot_row == null:
@@ -143,8 +138,7 @@ func set_effects_cleared_feedback() -> void:
return
slot_row.visible = true
slot_row.set_cleared_feedback()
var note := UiKit.label("状态随战斗结束消散", 11, false)
note.add_theme_color_override("font_color", UiKit.STONE_FAINT)
var note := UiKit.label("状态随战斗结束消散", 11, false, UiKit.STONE_FAINT)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
note.mouse_filter = Control.MOUSE_FILTER_IGNORE
var parent := slot_row.get_parent()
@@ -418,11 +412,11 @@ func _draw() -> void:
var ang := TAU * float(i) / 24.0
pts.append(sh_center + Vector2(cos(ang) * sh_rx, sin(ang) * sh_ry))
draw_colored_polygon(pts, Color(0, 0, 0, 0.27))
# 石板地台(v3.5——方向稿取色:板身 (150,130,100)/描边 (90,76,58)/顶缘受光线 2px (196,172,132);
# 几何沿用现行底座位〔margin 26/高 10→方向稿 18/12——按方向稿对齐〕)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), Color8(150, 130, 100), true)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), Color8(90, 76, 58), false, 1.0)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 2.0), Color8(196, 172, 132), true)
# 石板地台(v3.5——方向稿取色:板身/描边/顶缘受光线 2px——取色单点收 UiKit.STAND_STONE 族
# (T4 裸色收口);几何沿用现行底座位〔margin 26/高 10→方向稿 18/12——按方向稿对齐〕)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), UiKit.STAND_STONE, true)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 12.0), UiKit.STAND_STONE_EDGE, false, 1.0)
draw_rect(Rect2(18.0, box.y - 18.0, box.x - 36.0, 2.0), UiKit.STAND_STONE_RIM, true)
# 血条(直角递减):敌附顺位徽标(GOLD_DEEP ①②③)——旧 top VBox 几何等价(左 8 / 右 -8)
var bar_x := 8.0
+1 -2
View File
@@ -64,8 +64,7 @@ func _init() -> void:
_tip.visible = false
var sb := UiKit.panel_flat(UiKit.TIP_BG, UiKit.GOLD, 2, 1, Vector4(6, 3, 6, 3))
_tip.add_theme_stylebox_override("panel", sb)
_tip_label = UiKit.label("", 13, false)
_tip_label.add_theme_color_override("font_color", UiKit.WARM)
_tip_label = UiKit.label("", 13, false, UiKit.WARM)
_tip_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tip_label.custom_minimum_size = Vector2(210, 0)
_tip.add_child(_tip_label)
+1 -2
View File
@@ -43,8 +43,7 @@ func show_toast(text: String, tone: String = "info") -> void:
col = COL_GOLD
sb.border_color = col
row.add_theme_stylebox_override("panel", sb)
var lab := UiKit.label(("⚠ " if tone == "warn" else "") + text, 14, tone != "info")
lab.add_theme_color_override("font_color", col)
var lab := UiKit.label(("⚠ " if tone == "warn" else "") + text, 14, tone != "info", col)
lab.clip_text = true
row.add_child(lab)
_slot_box.add_child(row)
+7 -25
View File
@@ -1,15 +1,14 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-11(2026-09-12 code-review 修复批重构)
# 测试用角色数值调整板(模拟天赋树属性提升——仅测试,正式侧归元进度)。
# 纯视图边界(规范 1.3):本类只承载滑杆 UI 与生效值预览;加成应用 / 重置落表 / 批量模拟编排
# 全部经信号外发由组合根承接(apply_requested / reset_requested / simulate_requested / closed),
# 纯视图边界(规范 1.3):本类只承载滑杆 UI 与生效值预览;加成应用 / 批量模拟编排
# 全部经信号外发由组合根承接(apply_requested / simulate_requested / closed);
# 模拟进度与报告经 setter 注入——视图零核心写、零 smoke 脚本依赖、不拼报告文案(装配归 presenter)。
# 探针接缝(ADR-0004"合成输入走真实翻译出口"口径):press_apply_for_probe 等三键 = 按钮真实信号路径。
class_name TunePanel
extends Control
signal apply_requested(atk_pct: int, hp_pct: int) # 应用并关闭——加成落表归组合根单一应用路径
signal reset_requested() # 重置为 0——视图自清滑杆,落表归组合根
signal simulate_requested(count: int) # 批量模拟编排归组合根(表基线复位 + 单路径施加)
signal closed
@@ -106,39 +105,26 @@ func _apply_btn_pressed() -> void:
func _reset_btn_pressed() -> void:
reset_sliders()
reset_requested.emit()
func effective_atk() -> int:
return _scaled(_base_atk, atk_pct())
return UiKit.scaled(_base_atk, atk_pct())
func effective_max_hp() -> int:
return _scaled(_base_hp, hp_pct())
return UiKit.scaled(_base_hp, hp_pct())
# ---- 内部构建 ----
func _build() -> void:
var dim := ColorRect.new()
dim.color = UiKit.BG_OVERLAY
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(640, 0)
box.add_theme_constant_override("separation", 8)
center.add_child(box)
var box: VBoxContainer = UiKit.modal_shell(self, UiKit.BG_OVERLAY, Vector2(640, 0), 8).box
var title := UiKit.label("测试用 · 角色数值调整板", 26, true)
title.add_theme_color_override("font_color", UiKit.WARM)
var title := UiKit.label("测试用 · 角色数值调整板", 26, true, UiKit.WARM)
box.add_child(title)
var desc := UiKit.label(
"模拟玩家在循环中解锁天赋获得的属性提升(仅测试工具,不落盘)。\n百分比叠加基础值:攻击 %d / 生命 %d。影响所有模式(手动与模拟)。" % [_base_atk, _base_hp],
13, false)
desc.add_theme_color_override("font_color", UiKit.WARM_SOFT)
13, false, UiKit.WARM_SOFT)
box.add_child(desc)
box.add_child(HSeparator.new())
@@ -210,10 +196,6 @@ func _sync_labels() -> void:
_base_atk, effective_atk(), _base_hp, effective_max_hp()]
func _scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
func _emit_simulate_requested() -> void:
simulate_requested.emit(int(_sim_count.value))
+72 -7
View File
@@ -21,10 +21,7 @@ const INK_TITLE := Color8(30, 26, 22) # 亮底标题墨
const INK_ROUTE := Color8(24, 20, 16) # 节点图最深墨
const INK_ON_GOLD := Color8(60, 44, 20) # 金底上的深墨
const PANEL_HERO := Color8(232, 220, 196) # 面板底·主角侧(英雄卡/角色信息框)
const PANEL_ENEMY := Color8(226, 212, 190) # 面板底·敌方卡
const PANEL_DATA := Color8(226, 210, 182) # 面板底·数据框(AP 条/状态槽)
const PANEL_STAGE := Color8(216, 200, 172) # 面板底·战斗画面区
const PANEL_BORDER := Color8(120, 106, 88) # 面板细线框;兼熄灭点阵(DOT_OFF 别名)
const GOLD := Color8(240, 168, 40) # 奖励金
@@ -50,12 +47,10 @@ const DUSK := Color8(90, 108, 140) # 暮蓝(全作唯一暮蓝档
const WARM := Color8(240, 226, 200) # 暖白(暗底正文)
const WARM_SOFT := Color8(200, 188, 166) # 暖白次级(说明文)
const WARM_FAINT := Color8(178, 166, 146) # 暖白弱化(帮助文)
const WARM_ROUTE_SUB := Color8(210, 196, 170) # 节点图编号暖白
const STONE := Color8(120, 110, 98) # 灰石(通道/箭头/失效)
const STONE_FAINT := Color8(140, 124, 100) # 灰石弱化(占位提示文)
const DARK_UPCOMING := Color8(64, 58, 52) # 未达暗字(底/文同值)
const DARK_TEXT_FAINT := Color8(160, 150, 138)# 未达节点文字
const BRICK := Color8(201, 175, 131) # 棋盘·未开掩尘地砖 #C9AF83
const FLOOR := Color8(179, 154, 120) # 棋盘·已开裸土 #B39A78
@@ -73,10 +68,36 @@ const DIM := Color(0.08, 0.06, 0.05) # 遮罩基色(用时乘 alpha
const TOAST_BG := Color(0.94, 0.91, 0.84, 0.95) # toast 面底
const TIP_BG := Color(0.16, 0.12, 0.09, 0.96) # tooltip 深底
# 呈现杂色档(T4 裸色收口批 2026-10-02——各视图裸 Color8 字面量单点化;值 = 收口时各来源行现值快照)
const STAGE_BG := Color8(62, 52, 42) # B 区舞台暗底(方向稿 render_battle_screen——battle_stage_view 面板底)
const BEAM_LIGHT := Color8(255, 236, 200) # B 区顶光柱暖光(用时乘 alpha 渐隐——battle_stage_view 光柱层)
const STAND_STONE := Color8(150, 130, 100) # 站桩石板地台·板身(standee_body 方向稿取色)
const STAND_STONE_EDGE := Color8(90, 76, 58) # 站桩石板地台·描边
const STAND_STONE_RIM := Color8(196, 172, 132) # 站桩石板地台·顶缘受光线(board_cell_unit 口沿高光已并档至此——清扫批 2026-10-02)
const CAMP_HEADER_BAND := Color8(122, 82, 50) # 营地柜台头带深棕(4-camp.png 方向稿取色)
const CAMP_ROW_PLATE := Color8(240, 230, 206) # 营地行板暖米(同上)
const CAMP_BADGE_PANEL := Color8(40, 32, 24) # 营地钥石徽暗底(同上)
const HP_BAR_BACK := Color8(40, 34, 28) # 迷你血条深底(关卡画面样张 v1 冻结——对暗面板可辨)
# BBCode 十六进制(RichTextLabel 用;与上同值)
const HEX_GOLD := "f0a828"
const HEX_INK := "33261b"
const HEX_ON_GOLD := "3a2a08"
# 结算行/叙事行语义档(T4 收口批——battle_log_view 行格式化与 narrator 横幅行的裸 hex 单点化;
# 名随呈现语义、值 = 2026-09 起各行现值快照,色值逐字节不变)
const HEX_LOG_ROUND := "9a8a72" # 轮次前缀/零值行暖灰石
const HEX_LOG_GAIN := "e0b860" # 收益金(GP 袋/商店行/败判定/节点横幅)
const HEX_LOG_LOSS := "c47c7c" # 损失红(GP 流失/负效果)
const HEX_LOG_HEAL := "7cc47c" # 治疗绿(效果正值/治疗行/恢复点行)
const HEX_LOG_DMG_NUM := "d9402a" # 伤害数字朱(放大加色——数字即信息主角)
const HEX_LOG_DMG_KILL := "e07050" # 击杀行正文橙红
const HEX_LOG_DMG_BODY := "e0a050" # 伤害行正文橙
const HEX_LOG_VOIDED := "b08060" # 未生效行暗橙(⚠ 划线)
const HEX_LOG_CAST := "b09a72" # 施放行暖石(✦ 具名技能)
const HEX_LOG_DECAY := "8a9ab0" # 状态衰减蓝灰
const HEX_LOG_WIN := "80d080" # 胜利判定绿(模拟报告通关率同档)
const HEX_ROUTE_NOTE := "d0c090" # 奖励点预告暗金(narrator 行)
const HEX_SEED_NOTE := "c8a860" # 种子/开场行暗金(narrator 行)
# 语义别名(同值异名——语义站点用别名,值仍单点)
const DOT_OFF := PANEL_BORDER
@@ -163,15 +184,59 @@ static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int
return sb
static func label(text: String, size: int, bold: bool) -> Label:
# color 缺省哨兵 = alpha<0(真实用色不取负 alpha)——传色即一次到位(旧「label() 后跟
# add_theme_color_override」两行式收单行);省略时保持旧默认:bold=INK_LABEL / 非 bold=主题缺省
static func label(text: String, size: int, bold: bool, color: Color = Color(-1, -1, -1, -1)) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
if bold:
if color.a >= 0.0:
l.add_theme_color_override("font_color", color)
elif bold:
l.add_theme_color_override("font_color", INK_LABEL)
return l
# ── 模态壳工厂(T4 收口批 2026-10-02——各 overlay 手搓 dim+Center+VBox/Panel 骨架单点化)──
# dim = 全屏半透明遮罩(全屏锚定纪律同既有各 overlay);center 全屏锚定居中;with_panel = 内容包
# PanelContainer(panel_sb 缺省 = 主题默认板——settlement_review 同款);box = 内容列
#(min_size/separation 参数化,min_size ZERO = 不设)。返回 Dictionary{dim, center, panel, box}
#——探针直读的私有件由调用方按名摘引,结构同名同层级不变。
static func modal_dim(parent: Control, dim_color: Color) -> ColorRect:
var dim := ColorRect.new()
dim.color = dim_color
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
parent.add_child(dim)
return dim
static func modal_shell(parent: Control, dim_color: Color, min_size := Vector2.ZERO, separation := 12,
with_panel := false, panel_sb: StyleBox = null) -> Dictionary:
var dim := modal_dim(parent, dim_color)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var panel: PanelContainer = null
var host: Control = center
if with_panel:
panel = PanelContainer.new()
if panel_sb != null:
panel.add_theme_stylebox_override("panel", panel_sb)
center.add_child(panel)
host = panel
var box := VBoxContainer.new()
if min_size != Vector2.ZERO:
box.custom_minimum_size = min_size
box.add_theme_constant_override("separation", separation)
host.add_child(box)
return {"dim": dim, "center": center, "panel": panel, "box": box}
# 属性百分比合成(main._debug_hero_override 与 TunePanel 生效值预览共用单点——两处同式实现收口)
static func scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
static func button(text: String) -> Button:
var b := Button.new()
b.text = text
+37
View File
@@ -0,0 +1,37 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 战斗驱动循环单点(T6 样板收口批 2026-10-02)——smoke 族探针共用的「挖掘至停手」整轮驱动。
# 策略 = AutoPlayer.choose_dig(O4 单一实现):挖最近可挖格(异格先移位)/ 无可挖或相位离 Active 停手。
# 语义锚(monte_carlo 原型逐位保持):guard 400 步防挂死;移位后相位/轮次离场即 break;
# 轮末仍 Active 且未换轮 → settle_batch stop。smoke_all._run_battle/_auto_play_round 为冒烟本体
# 不经本面(含回归断言——2026-10-02 修复批加,禁并入);fx_shot_probe 为带墙钟抓帧的异步变体亦不在列。
extends RefCounted
const AutoPlayer := preload("res://smoke/auto_player.gd")
const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值)
# 驱动一整轮挖掘。still_running = 调用方战斗活性谓词(缺省恒真):
# monte_carlo / skill_rhythm 传 `func(): return out.result == ""`(battle_finished 已发的轮内不再动手),
# damage_probe 关卡驱动传 runner 绑定 + battle_result 谓词,battle 级探针传空即可。
static func drive_round(settlement, still_running: Callable = Callable()) -> void:
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := GUARD_ACTIONS
while _alive(still_running) and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and guard > 0:
guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if _alive(still_running) and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
static func _alive(still_running: Callable) -> bool:
return still_running.is_null() or bool(still_running.call())
+1
View File
@@ -0,0 +1 @@
uid://dbattledrv13r
+8 -34
View File
@@ -12,7 +12,7 @@
# Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- level 42
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd")
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
var _checks := 0
var _fails := 0
@@ -67,7 +67,7 @@ func _probe_boss(db, seed_val: int) -> void:
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
_print_rounds(db, math, run_a, true)
_print_rounds(db, math, run_a)
var a_ok := _assert_rounds(db, math, run_a, true)
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack)
@@ -77,7 +77,7 @@ func _probe_boss(db, seed_val: int) -> void:
print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---")
var run_b := _run_boss(db, seed_val, true)
_print_rounds(db, math, run_b, true)
_print_rounds(db, math, run_b)
var b_ok := _assert_rounds(db, math, run_b, false)
check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack)
check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
@@ -98,28 +98,16 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
var rounds := 0
while settlement.battle_result == "" and rounds < 40:
rounds += 1
var start_round: int = settlement.round_index
if dig:
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0:
guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if settlement.action_economy.phase == "Active":
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement)
else:
settlement.minesweeper.settle_batch([{"action": "stop"}])
return cap
# 逐轮:授予行汇总 → 重建战斗段时点池值(effect_pool_map 表驱动归属)→ 敌行动行逐行打印
func _print_rounds(db, math, run: Dictionary, verbose: bool) -> void:
#(T6 收口批:原形参 verbose 为死参——打印行为无档位差异,随批删除)
func _print_rounds(db, math, run: Dictionary) -> void:
var groups := _round_groups(run.rows)
var beats := _played_beats(run, db)
var rounds: Array = groups.keys()
@@ -356,22 +344,8 @@ func _drive_battle(runner, settlement, cap: Dictionary) -> void:
var round_guard := 60
while cap["result"] == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var action_guard := 400
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0:
action_guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool:
return runner.current_battle() == settlement and settlement.battle_result == "")
# 战斗判定摘要:round-1 victory 的战斗必须能从结算行分辨「首轮全灭」vs「空场」——
+2 -16
View File
@@ -5,7 +5,7 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/dump_battle.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
func _initialize() -> void:
var db = load("res://core/config_db.gd").new()
@@ -28,21 +28,7 @@ func _initialize() -> void:
var rounds := 0
while settlement.battle_result == "" and rounds < 40:
rounds += 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var guard := 400
while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0:
guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if action_economy.phase == "Active":
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement) # T6 收口:原内联循环语义同型(移动期免 AP——移动不换轮,移位后重查 break 分支为防御性等价)
print("战斗结果: %s(%d 轮)" % [settlement.battle_result, rounds])
print("=== 结算步骤流(%d 行)===" % stream.size())
for row in stream:
+16 -24
View File
@@ -6,7 +6,16 @@
# 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
# ── 演出采样窗口口径(拍板点:headless/窗口化帧率不定,帧时刻以墙钟近似锚定;改窗口 = 改抓帧
# 时刻观测面,非断言语义。窗口值与原字面量逐一同值——T6 收口批仅提具名。) ──
const T_DIG_FLIGHT := 0.12 # 挖格行到达/弹道在飞窗口(settle_batch 回执后停留)
const T_SETTLE := 0.35 # 结算排播窗口(行到达后二连抓)
const T_ROUND_TICK := 0.25 # 停手轮末(敌方攻击弹道/受击闪白 0.2-1.6s 流过)连抓 6 帧的采样步距
const T_FINAL := 0.6 # 收尾帧前静置
var main: Node
var out_dir := ""
var _problems: Array = []
@@ -19,17 +28,10 @@ func _ready() -> void:
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
for p0 in ["user://fx_shot_probe.json", "user://fx_shot_probe.json.tmp", "user://fx_shot_probe_settings.cfg"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://fx_shot_probe.json")
iso_c.load_from_disk()
main.save_manager = iso_c
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
main.settings_store = load("res://presentation/settings_store.gd").new("user://fx_shot_probe_settings.cfg")
ProbeUiUtil.isolate_save(main, "fx_shot_probe", false, true, false)
for i in 8:
await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口)
var lvbtn: Button = _find_button_by_sub(main._start_overlay, "开始冒险")
var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains")
if lvbtn == null:
_fail("开始冒险按钮缺失")
return
@@ -68,9 +70,9 @@ func _ready() -> void:
if out.is_empty() or not bool(out[0].get("accepted", false)):
continue
dug += 1
await get_tree().create_timer(0.12).timeout
await get_tree().create_timer(T_DIG_FLIGHT).timeout
await _snap("fx_dig%d" % dug) # 行到达/弹道在飞窗口
await get_tree().create_timer(0.35).timeout
await get_tree().create_timer(T_SETTLE).timeout
await _snap("fx_settle%d" % dug) # 结算排播窗口
continue
# 无相邻可挖 → 走向有未开安全格邻居的已揭开格(挖掘期移动免费)
@@ -90,12 +92,12 @@ func _ready() -> void:
# 停手 → 轮末结算(敌方攻击弹道/受击闪白/结算排播在 0.2-1.6s 窗口流过)——连抓 6 帧
rules.settle_batch([{"action": "stop"}])
for i in 6:
await get_tree().create_timer(0.25).timeout
await get_tree().create_timer(T_ROUND_TICK).timeout
await _snap("fx_round%d" % (i + 1))
if dug == 0:
_fail("无安全格可挖(首板全雷不可信)")
return
await get_tree().create_timer(0.6).timeout
await get_tree().create_timer(T_FINAL).timeout
await _snap("fx_final")
if _problems.is_empty():
print("[fx_shot_probe] PASS — 演出帧 %d 张落 %s(挖格 %d)" % [dug * 2 + 1, out_dir, dug])
@@ -115,16 +117,6 @@ func _snap(tag: String) -> void:
img.save_png("%s/%s.png" % [out_dir, tag])
func _find_button_by_sub(root: Node, needle: String) -> Button:
if root is Button and String(root.text).contains(needle):
return root
for c in root.get_children():
var r := _find_button_by_sub(c, needle)
if r != null:
return r
return null
func _fail(msg: String) -> void:
_problems.append(msg)
push_error("[fx_shot_probe] FAIL — " + msg)
+18 -50
View File
@@ -15,7 +15,6 @@
# 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大——
# 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 通关兑付停点确认、adventure_finished 置 result/summary;外层每关重取 runner 实例经 _drive_level
# 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与返回结构零变化,行为逐位不变。
# 2026-10-01 迭代 10 奖励重构批:大奖励立即确认改三选一停点驱动(候选取首件/替换放弃新件)。
@@ -396,13 +395,17 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void:
static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void:
if String(st.get("policy", "normal")) == "perfect":
_autoplay_battle_perfect(runner, settlement, st, logger)
return
# 策略档经 _decide_act 单点分派(T6 样板收口批 2026-10-02——原 _autoplay_battle/_autoplay_battle_perfect
# 双循环逐行相同仅差 act 来源行,合并单循环;三档决策语义逐位不变,见 _decide_act)。
# 普通策略档 v3(2026-10-01 用户重定义):完美策略 + 受控失误注入——v2「新手级单步推理+盲挖
# 兜底」画像被真人实证推翻(零加成真人首通三关踩 23 雷 vs v2 普通档 L2 条件通关率 10.6%)。
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/标旗/卡死刷新),失误只注入两手——
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
# 完美策略档(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
@@ -413,11 +416,7 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
action_guard -= 1
var act: Dictionary
if bool(st.get("adv", false)):
act = _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
else:
act = AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
var act: Dictionary = _decide_act(st, settlement)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
@@ -487,47 +486,16 @@ static func _gamble_action(plan: Dictionary, settlement) -> Dictionary:
return {"kind": "move", "cell": plan.hero_cell}
# 完美策略档战斗驱动(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
static func _autoplay_battle_perfect(runner, settlement, st: Dictionary, logger) -> void:
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
st.rounds += 1
var start_round: int = settlement.round_index
var action_guard := 400
var rejects := 0
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
action_guard -= 1
var act := AutoPlayer.perfect_action(settlement.minesweeper, settlement)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var ok := false
if kind == "move":
ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round)
elif kind == "dig":
ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round)
elif kind == "flag":
ok = _submit_intent(settlement, "flag_place", act.get("cell"), logger, start_round)
elif kind == "refresh":
ok = bool(settlement.player_request_refresh())
if logger != null:
logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round})
if ok:
if kind == "refresh":
st.refreshes = int(st.refreshes) + 1
rejects = 0
else:
rejects += 1
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
if logger != null:
logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round})
# 战斗决策单行分派(T6 双循环合并抽取——原两循环唯一差异行):
# perfect 档 = 完美策略单步决策(上界口径);
# normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。
# 决策函数纯读板面/编排面(零 RNG 消费——失误流独走 st.prng,见 _apply_normal_slips)。
static func _decide_act(st: Dictionary, settlement) -> Dictionary:
if String(st.get("policy", "normal")) == "perfect":
return AutoPlayer.perfect_action(settlement.minesweeper, settlement)
if bool(st.get("adv", false)):
return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool:
+3 -21
View File
@@ -5,7 +5,7 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/monte_carlo.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口(批注 2026-09-15:原文件缺失 preload——HEAD 起即解析失败,本批顺手修复)
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
const N := 30
const SEED0 := 42
@@ -43,7 +43,7 @@ func _run_encounter(db, L: int) -> void:
else:
timeouts += 1
print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct(rounds_v, 0.1), _pct(rounds_v, 0.9), _hist(rounds_v)])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)])
print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)])
print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)])
@@ -72,21 +72,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
while out.result == "" and guard_rounds > 0:
guard_rounds -= 1
out.rounds += 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
if out.result == "":
out.result = "timeout"
return out
@@ -114,10 +100,6 @@ func _median_f(a: Array) -> float:
return float(b[b.size() / 2])
func _pct(a: Array, p: float) -> float:
return _pct_f(a, p)
func _pct_f(a: Array, p: float) -> float:
if a.is_empty():
return 0.0
+112
View File
@@ -0,0 +1,112 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 探针共用面:树寻址助手 + 存档/设置/元进度隔离样板单点(T6 样板收口批 2026-10-02)。
# 收口范围:ui_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的 7 处树寻址样板
# 与 7 处隔离样板(各探针隔离文件名后缀 tag 保持不变)。
# 用法:探针侧 `const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd")` 后静态调用
#(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。
extends RefCounted
const SaveManagerScript := preload("res://core/save_manager.gd")
const MetaProgressionScript := preload("res://core/meta_progression.gd")
const SettingsStoreScript := preload("res://presentation/settings_store.gd")
# ── 树寻址(递归只读——按钮/标签句柄未存组合根成员的探针专用面) ──────────────
# 匹配模式参数化(历史两种语义,禁混用禁静默改变):
# mode="prefix" = text.begins_with(needle)(ui_probe / ui_click_probe 历史语义)
# mode="contains" = text.contains(needle)(shot_probe / fx_shot_probe 历史语义)
# 调用点替换时逐一核对原语义后选参。
static func find_button(root: Node, needle: String, mode: String = "prefix") -> Button:
if root == null:
return null
if root is Button and _text_match(String(root.text), needle, mode):
return root
for child in root.get_children():
var found := find_button(child, needle, mode)
if found != null:
return found
return null
# CheckButton 同款寻址(ui_click_probe 历史语义 = contains——设置面旧入口退役断言用)
static func find_check_button(root: Node, needle: String) -> CheckButton:
if root == null:
return null
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := find_check_button(child, needle)
if found != null:
return found
return null
# Label 按文案包含寻址(shot_probe GP 徽章读数锚)
static func find_label(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for child in root.get_children():
var found := find_label(child, text_fragment)
if found != null:
return found
return null
# 控件实测几何(shot_probe checker 文字框取样坐标源——禁硬编码像素,分辨率档位自适应)
static func label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for child in root.get_children():
var r := label_rect(child, text_fragment)
if not r.is_empty():
return r
return {}
# 按脚本名寻址(resource_path contains——shot_probe hero 面板锚)
static func find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for child in root.get_children():
var r := find_by_script(child, script_name)
if r != null:
return r
return null
static func _text_match(text: String, needle: String, mode: String) -> bool:
return text.begins_with(needle) if mode == "prefix" else text.contains(needle)
# ── 存档/设置/元进度三面隔离(探针禁碰真实档 user://save.json——SaveManager 路径注入纪律) ──
# tag = 隔离档名(不带扩展名,如 "smoke_input_probe_save"——各探针后缀保持不变);先删后建防陈旧档泄入。
# mark_first_done = 对齐真实档游玩史口径(模板抽签路径不变);with_settings = 连设置面换注
#(隔离 cfg 名 = <tag>_settings.cfg,与被收口探针原文件名逐一同名);refresh_resume = 组合根
# 「继续上局」按钮重刷;swap_save = 是否换注 main.save_manager(营地先行段只换 meta 不换 save_manager)。
# 返回隔离 SaveManager 实例(调用方常需后续直读余额/重读档)。
static func isolate_save(main, tag: String, mark_first_done: bool = false, with_settings: bool = false,
refresh_resume: bool = false, swap_save: bool = true):
var save_path := "user://%s.json" % tag
for p in [save_path, save_path + ".tmp", "user://%s_settings.cfg" % tag]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.mark_first_adventure_done()
if swap_save:
main.save_manager = iso
main.meta = MetaProgressionScript.new(main.db, iso)
if with_settings:
main.settings_store = SettingsStoreScript.new("user://%s_settings.cfg" % tag)
if refresh_resume:
main._refresh_resume_button()
return iso
+1
View File
@@ -0,0 +1 @@
uid://c1probeuiutil
+14 -71
View File
@@ -9,7 +9,9 @@
# 最小自检 = 四件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main: Node
var out_dir := ""
var _problems: Array = []
var _meta := {} # 控件实测几何(全局 rect——shot_checker 文字框/明度带取样坐标源,禁硬编码像素)
@@ -28,36 +30,28 @@ func _ready() -> void:
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染) ──
for p0 in ["user://smoke_shot_probe.json", "user://smoke_shot_probe.json.tmp",
"user://smoke_shot_probe_settings.cfg"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://smoke_shot_probe.json")
iso_c.load_from_disk()
main.save_manager = iso_c
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
main.settings_store = load("res://presentation/settings_store.gd").new("user://smoke_shot_probe_settings.cfg")
# ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染;历史语义 = contains 寻址) ──
ProbeUiUtil.isolate_save(main, "smoke_shot_probe", false, true, false)
await get_tree().process_frame
# ① 菜单(启动态)
_meta["menu_sub"] = _find_label_rect(main._start_overlay, "改造批")
_meta["menu_help"] = _find_label_rect(main._start_overlay, "玩法:")
_meta["menu_title"] = _find_label_rect(main._start_overlay, "Dungeon Sweeper")
_meta["menu_sub"] = ProbeUiUtil.label_rect(main._start_overlay, "改造批")
_meta["menu_help"] = ProbeUiUtil.label_rect(main._start_overlay, "玩法:")
_meta["menu_title"] = ProbeUiUtil.label_rect(main._start_overlay, "Dungeon Sweeper")
await _snap("menu")
# ② 营地
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "contains")
if campbtn == null:
_problems.append("营地入口缺失(开始层 campbtn)")
else:
campbtn.pressed.emit()
await get_tree().process_frame
await _snap("camp")
var close_btn: Button = _find_button_by_prefix(main.camp_overlay, "关闭营地")
var close_btn: Button = ProbeUiUtil.find_button(main.camp_overlay, "关闭营地", "contains")
if close_btn != null:
close_btn.pressed.emit()
await get_tree().process_frame
# ③ 关卡画面(开始冒险→停点出图——冒险开始即出图,停点前移批语义)
var lvbtn: Button = _find_button_by_prefix(main._start_overlay, "开始冒险")
var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains")
if lvbtn == null:
_problems.append("开始冒险按钮缺失(开始层 lvbtn)")
else:
@@ -66,7 +60,7 @@ func _ready() -> void:
await get_tree().process_frame
await _snap("level")
# GP 常驻徽章断言(迭代 9 卡⑪核验——读数=runner.gp 现值,刷新=写路径回执后重查〔open_choices〕)
var gp_label: Label = _find_label_by_text(main.level_screen, "◈ ")
var gp_label: Label = ProbeUiUtil.find_label(main.level_screen, "◈ ")
if gp_label == null or not gp_label.is_visible_in_tree():
_problems.append("GP 徽章不可见(关卡画面顶栏)")
elif main.level_screen.get("_runner") != null:
@@ -80,8 +74,8 @@ func _ready() -> void:
await get_tree().process_frame
await _snap("battle")
# 战斗屏文字框/明度带锚点(全局 rect——checker 取样坐标源)
_meta["battle_log_hint"] = _find_label_rect(main.stage, "结算明细")
_meta["hero_panel"] = _node_rect(_find_by_script(main, "hero_info_panel_view"))
_meta["battle_log_hint"] = ProbeUiUtil.label_rect(main.stage, "结算明细")
_meta["hero_panel"] = _node_rect(ProbeUiUtil.find_by_script(main, "hero_info_panel_view"))
_meta["stage_view"] = _node_rect(main.stage)
_meta["board_zone"] = _node_rect(main.board_zone)
# draw calls 真机测量(迭代 9 卡⑦——基线 270→目标 <200;窗口化渲染帧读数,
@@ -121,59 +115,8 @@ func _snap(key: String) -> void:
print("[shot_probe] saved %s (%dx%d)" % [key, img.get_width(), img.get_height()])
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root == null:
return null
if root is Button and String(root.text).contains(prefix):
return root
for c in root.get_children():
var r := _find_button_by_prefix(c, prefix)
if r != null:
return r
return null
# 控件实测几何采集(checker 断言坐标源——禁硬编码像素,分辨率档位自适应)
func _find_label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for c in root.get_children():
var r := _find_label_rect(c, text_fragment)
if not r.is_empty():
return r
return {}
func _find_label_by_text(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for c in root.get_children():
var r := _find_label_by_text(c, text_fragment)
if r != null:
return r
return null
func _node_rect(n: Node) -> Dictionary:
if n == null or not (n is CanvasItem):
return {}
return {"x": n.global_position.x, "y": n.global_position.y,
"w": n.size.x, "h": n.size.y}
func _find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for c in root.get_children():
var r := _find_by_script(c, script_name)
if r != null:
return r
return null
+3 -16
View File
@@ -7,6 +7,8 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/skill_rhythm_probe.gd
extends SceneTree
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
func _initialize() -> void:
var db = load("res://core/config_db.gd").new()
db.load_tables("res://data/tables", false)
@@ -50,23 +52,8 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di
casts[sid] = casts.get(sid, 0) + 1)
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
return out
var AutoPlayer = load("res://smoke/auto_player.gd")
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
guard_rounds -= 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
guard -= 1
var plan = AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
return out
+29 -18
View File
@@ -6,15 +6,20 @@
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load)
var _failures := 0
var _checks := 0
func check(cond: bool, label: String) -> void:
func check(cond: bool, label: String, quiet: bool = false) -> void:
_checks += 1
if cond:
print(" [PASS] " + label)
if not quiet:
print(" [PASS] " + label)
elif quiet:
_failures += 1
print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持
else:
_failures += 1
print(" [FAIL] " + label)
@@ -22,7 +27,7 @@ func check(cond: bool, label: String) -> void:
func _initialize() -> void:
print("=== Dungeon Sweeper 垂直切片冒烟 ===")
var db = load("res://core/config_db.gd").new()
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
check(load_ok, "全部表加载 + 校验通过(含跨表护栏)")
if not load_ok:
@@ -238,7 +243,7 @@ func _smoke_adventure_api() -> void:
# 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理
var autoplay = load("res://smoke/level_autoplay.gd")
var battle = adr.runner().current_battle()
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st := _battle_st("normal")
var guard := 30
while adr.runner().current_battle() == battle and guard > 0:
guard -= 1
@@ -746,7 +751,7 @@ func _smoke_level() -> void:
var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest)
check(rest.rest_healed == expect, "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max])
else:
check(false, "恢复点未到达(seed 4242 hp+100 在恢复点前败北?%s)" % rest.result)
check(false, "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result)
# ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关)
var lose = autoplay.run_level(_db, 42, 1, -80, -80)
check(lose.result == "defeat", "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result)
@@ -951,7 +956,8 @@ func _weaken_refresh_probe() -> Dictionary:
settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动
for eff in settlement._status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._clamped_pool("enemy_output"))}
# T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役)
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))}
return {}
@@ -1274,6 +1280,11 @@ func _run_battle(L: int, D: int) -> Dictionary:
if out.result == "":
out.result = "timeout"
out.terminated = out.result == "victory" or out.result == "defeat"
if out.terminated:
# P0-3 回归断言(2026-10-02 修复批):notify_combat_end 曾被并进行尾注释从不执行——
# 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓
check(action_economy.phase == "Idle",
"战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase))
return out
@@ -1756,7 +1767,7 @@ func _smoke_encounter_roll() -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(30000 + i)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
check_silent(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}))
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true)
counts.append(settlement.presentation_snapshot().enemies.size())
# 区间生效:20 个种子应出现 1 和 2(D0 [1,2])
check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts))
@@ -2413,11 +2424,11 @@ func _has_effect_id(rows: Array, effect_id: String) -> bool:
return true
return false
func check_silent(cond: bool) -> void:
_checks += 1
if not cond:
_failures += 1
print(" [FAIL] (silent check)")
# 手搓战斗状态袋单点(T6 收口批——7 处字面量收编;_autoplay_battle 直接消费的最小字段族。
# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面)
func _battle_st(policy: String) -> Dictionary:
return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy}
# ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ====================
@@ -2879,7 +2890,7 @@ func _smoke_level_graph() -> void:
var choice_sizes: Array = []
runner.choices_presented.connect(func(c): choice_sizes.append(c.size()))
check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)")
var st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st = _battle_st("normal")
var first = runner.current_battle()
var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变)
first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", "")))
@@ -2904,7 +2915,7 @@ func _smoke_level_graph() -> void:
for i in range(12):
var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g_runner.start_level(2, 9100 + i)
var g_st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"}
var g_st = _battle_st("perfect")
var g_first = g_runner.current_battle()
var g_box := {}
g_first.battle_finished.connect(func(p):
@@ -2985,7 +2996,7 @@ func _smoke_level_graph() -> void:
var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g3_runner.set_hero_override(200, 100)
g3_runner.start_level(3, 9100 + i)
var g3_st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"}
var g3_st = _battle_st("perfect")
var g3_first = g3_runner.current_battle()
var g3_box := {}
g3_first.battle_finished.connect(func(p):
@@ -3031,7 +3042,7 @@ func _smoke_gp_wallet() -> void:
var rows: Array = []
battle.step_row.connect(func(row): rows.append(row))
battle.battle_finished.connect(func(p): fin["p"] = p)
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st := _battle_st("normal")
var guard := 30
while runner.current_battle() == battle and not fin.has("p") and guard > 0:
guard -= 1
@@ -3195,7 +3206,7 @@ func _smoke_shop_room() -> void:
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s))
runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言)
runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st := _battle_st("normal")
var guard := 40
while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0:
guard -= 1
@@ -3290,7 +3301,7 @@ func _smoke_shop_room() -> void:
var b_end = runner.current_battle()
if b_end == null:
break
autoplay._autoplay_battle(runner, b_end, {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}, null)
autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null)
check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"],
"冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", "")))
check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)")
+29 -67
View File
@@ -11,8 +11,9 @@
extends Node
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4:自动玩家策略单一实现
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main
var main: Node
var _last_state := {}
var _stall_frames := 0
var _closed_via_abandon := false # T17——预算耗尽经放弃路径收口(断点段返回断言豁免位)
@@ -42,14 +43,11 @@ func _ready() -> void:
var t_ui_ready := Time.get_ticks_msec() - t0
# ---- 营地购买合成点击路径(元进度实现批——campbtn/购买/关闭全走真实按钮信号路径;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json);陈旧档段首清理)----
for p0 in ["user://smoke_click_probe_camp.json", "user://smoke_click_probe_camp.json.tmp",
"user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json")
iso_c.load_from_disk()
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
DirAccess.remove_absolute(p0) # 重置档段前清场兜底(营地/设置段不触该档——防陈旧断点泄入后续段)
var iso_c = ProbeUiUtil.isolate_save(main, "smoke_click_probe_camp", false, false, false, false)
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix")
if campbtn == null:
_problems.append("营地入口按钮缺失(开始层 campbtn)")
else:
@@ -91,7 +89,7 @@ func _ready() -> void:
iso_s.load_settings()
main.settings_store = iso_s
main.settings_overlay.retarget(iso_s)
var settings_btn: Button = _find_button_by_prefix(main._start_overlay, "设置")
var settings_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix")
if settings_btn == null:
_problems.append("设置入口按钮缺失(开始层 settingsbtn)")
else:
@@ -102,7 +100,7 @@ func _ready() -> void:
else:
# 减动效转正双态(卡④ AC③):开=0.5 引擎态即时生效+落盘持久;关=1.0 回常态
# T17 随批:按钮语义化改文案「结算演出动画:完整/快速」(状态值内联+操作提示)
var motion_btn: Button = _find_button_by_text(main.settings_overlay, "结算演出动画")
var motion_btn: Button = ProbeUiUtil.find_button(main.settings_overlay, "结算演出动画", "contains")
if motion_btn == null:
_problems.append("设置面减动效开关缺失")
else:
@@ -129,10 +127,10 @@ func _ready() -> void:
if absf(iso_s.volume_linear - 0.3) > 0.001:
_problems.append("音量未入 store(%f ≠ 0.3)" % iso_s.volume_linear)
# 调试入口默认隐藏门控(卡④ AC④——探针环境 DS_DEBUG 未设)
var dbg_btn: Button = _find_button_by_prefix(main._start_overlay, "调试")
var dbg_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "调试", "prefix")
if dbg_btn != null and dbg_btn.visible:
_problems.append("调试入口未隐藏(DS_DEBUG 门控失守——卡④ AC④)")
var settings_close: Button = _find_button_by_prefix(main.settings_overlay, "返回")
var settings_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix")
if settings_close == null:
_problems.append("设置面返回按钮缺失")
else:
@@ -141,25 +139,17 @@ func _ready() -> void:
if main.settings_overlay.visible or not main._start_overlay.visible:
_problems.append("设置面关闭后未恢复开始层")
# 旧入口退役断言(卡④ AC③——测试调整板不再含减动效开关)
var legacy_motion = _find_checkbutton_by_text(main.tune_panel, "减动效")
var legacy_motion = ProbeUiUtil.find_check_button(main.tune_panel, "减动效")
if legacy_motion != null:
_problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)")
# D1 断言(二轮):退出游戏只在一级菜单——开始菜单有、设置面无
if _find_button_by_prefix(main._start_overlay, "退出游戏") == null:
if ProbeUiUtil.find_button(main._start_overlay, "退出游戏", "prefix") == null:
_problems.append("开始菜单缺退出游戏按钮(二轮 D1)")
if _find_button_by_prefix(main.settings_overlay, "退出游戏") != null:
if ProbeUiUtil.find_button(main.settings_overlay, "退出游戏", "prefix") != null:
_problems.append("设置面仍含退出游戏按钮(二轮 D1——只在一级菜单出现)")
# 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底)
for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json")
iso_reset.load_from_disk()
iso_reset.mark_first_adventure_done() # 对齐基线(真实档已有游玩史)——非首次脚本模式=逐关抽签模板
main.save_manager = iso_reset # 迭代 8 卡①——冒险断点写盘同步隔离(禁碰真实档 user://save.json)
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset)
main._refresh_resume_button()
var resume_btn0: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局")
ProbeUiUtil.isolate_save(main, "smoke_click_probe_reset", true, false, true) # 零档换注 + 继续上局按钮重刷(原三行样板收口)
var resume_btn0: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn0 == null:
_problems.append("继续上局按钮缺失(开始层 resumebtn——装配器)")
elif resume_btn0.visible:
@@ -188,7 +178,10 @@ func _ready() -> void:
_problems.append("首格未可点(cells=0)")
print("[click_probe] 首格可点计时 %dms(进入战斗屏口径,≤500ms AC)· 场景冷启动 %dms(含 UI 构建 %dms,观测项)" % [
first_cell_ms, t_total, t_ui_ready])
var action_budget := 20000
const ACTION_BUDGET_FRAMES := 20000 # 主循环帧预算(坏态挂死兜底上限——常态通关远低于此;有挂帧证据才收紧,勿让坏态等满预算才报)
var action_budget := ACTION_BUDGET_FRAMES
# 2026-10-02 修复批(P0-2):c44fb1b 加 bonus 分支时把本 while 循环体整体去缩进——continue 落在
# 循环外成解析错误、探针不可加载且改后无人实跑。此处还原循环体缩进,bonus 分支保留为体内兄弟分支。
while not _done and action_budget > 0:
action_budget -= 1
await get_tree().process_frame
@@ -197,9 +190,9 @@ func _ready() -> void:
_flags_placed.clear()
continue
if main.bonus_overlay.visible:
# 迭代 10 三选一停点(BonusPickOverlay——奖励节点/通关兑付共用面;T17 硬锁批适配——
# 本探针此前未适配该层,走到即卡死)。三态分派:替换子态(满位——放弃新件)/
# result 态(兑付反馈——点继续推进)/ 待选态(点首件+确认)。真实信号路径全线。
# 迭代 10 三选一停点(BonusPickOverlay——奖励节点/通关兑付共用面;T17 硬锁批适配——
# 本探针此前未适配该层,走到即卡死)。三态分派:替换子态(满位——放弃新件)/
# result 态(兑付反馈——点继续推进)/ 待选态(点首件+确认)。真实信号路径全线。
if main.bonus_overlay._replace_box.visible:
main.bonus_overlay.replace_chosen.emit(-1)
elif main.bonus_overlay._continue_btn.visible:
@@ -228,7 +221,7 @@ func _ready() -> void:
_problems.append("大奖励屏打开时输入未锁定")
_inter_confirms += 1
if _inter_confirms == 1:
var next_btn: Button = _find_button_by_prefix(main._inter_overlay, "进入下一层")
var next_btn: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix")
if next_btn == null:
_problems.append("大奖励屏确认按钮缺失")
else:
@@ -326,11 +319,11 @@ func _ready() -> void:
elif main.shop_overlay.visible:
main.shop_overlay.leave_pressed.emit()
elif main._inter_overlay.visible:
var next_btn2: Button = _find_button_by_prefix(main._inter_overlay, "进入下一层")
var next_btn2: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix")
if next_btn2 != null:
next_btn2.pressed.emit()
elif main.level_screen.visible:
var abandon_btn: Button = _find_button_by_prefix(main.level_screen, "放弃")
var abandon_btn: Button = ProbeUiUtil.find_button(main.level_screen, "放弃", "prefix")
if abandon_btn != null:
_closed_via_abandon = true
abandon_btn.pressed.emit()
@@ -364,11 +357,11 @@ func _ready() -> void:
# 战败态前提失配——记录不计 problem(返回路径已由 T17 第 27 项实机目检验证)。
if not _closed_via_abandon:
_problems.append("断点段:终局返回未回开始菜单")
var resume_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局")
var resume_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn != null and resume_btn.visible:
_problems.append("断点段:退出清断点后继续上局按钮未隐藏(卡②/T3 AC③)")
# ② 中断→续玩:新冒险达入口停点=断点在档 → 合成回菜单(模拟关游戏重开)→ 按钮点亮 → 点击恢复至停点
var lv_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 开始冒险")
var lv_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 开始冒险", "prefix")
if lv_btn == null or resume_btn == null:
_problems.append("断点段:开始冒险/继续上局按钮缺失")
else:
@@ -412,7 +405,7 @@ func _ready() -> void:
await get_tree().process_frame
if not main.confirm_dialog.is_open() or main.adventure != null:
_problems.append("断点段:有断点点开始冒险未弹确认框(二轮 A 失守)")
var cancel_btn: Button = _find_button_by_text(main.confirm_dialog, "取消")
var cancel_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "取消", "contains")
if cancel_btn == null:
_problems.append("断点段:确认框取消按钮缺失")
else:
@@ -422,7 +415,7 @@ func _ready() -> void:
_problems.append("断点段:取消未回开始菜单(二轮 A)")
lv_btn.pressed.emit()
await get_tree().process_frame
var confirm_btn: Button = _find_button_by_text(main.confirm_dialog, "开新冒险")
var confirm_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "开新冒险", "contains")
if confirm_btn == null:
_problems.append("断点段:确认框开新冒险按钮缺失")
else:
@@ -608,34 +601,3 @@ func _dump_state() -> void:
str(diag.battle_result), str(diag.settling)])
print(" board_installed=%s facts_path='%s' refresh_used=%s flags=%s" % [
str(diag.minesweeper_board_installed), str(diag.facts_path), str(diag.refresh_used_round), str(_flags_placed)])
# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;与 ui_probe 同款助手)
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root is Button and String(root.text).begins_with(prefix):
return root
for child in root.get_children():
var found := _find_button_by_prefix(child, prefix)
if found != null:
return found
return null
func _find_button_by_text(root: Node, needle: String) -> Button:
if root is Button and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := _find_button_by_text(child, needle)
if found != null:
return found
return null
func _find_checkbutton_by_text(root: Node, needle: String) -> CheckButton:
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := _find_checkbutton_by_text(child, needle)
if found != null:
return found
return null
+18 -12
View File
@@ -13,7 +13,9 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_input_probe.tscn
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
var main: Node
var _problems: Array = []
var _actions: Array = [] # action_requested 收集([cell, kind])
var _chords: Array = [] # chord_attempted 收集
@@ -26,16 +28,9 @@ func _ready() -> void:
add_child(main)
await get_tree().process_frame
# 存档隔离(迭代 8 卡①②——断点写盘面随冒险挂 main.save_manager;探针禁碰真实档 user://save.json,
# 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变)
for p0 in ["user://smoke_input_probe_save.json", "user://smoke_input_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso = load("res://core/save_manager.gd").new("user://smoke_input_probe_save.json")
iso.load_from_disk()
iso.mark_first_adventure_done()
main.save_manager = iso
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
main._refresh_resume_button()
# 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_input_probe_save", true, false, true)
main._start_adventure_from_menu()
await get_tree().process_frame
# ---- ⑫ 图上命中真实输入(关卡画面实装批 2026-09-28 回归闸):入口停点候选节点中心
@@ -99,6 +94,8 @@ func _ready() -> void:
var maxl2 := 0
for k2 in layer2.keys():
maxl2 = maxi(maxl2, int(layer2[k2]))
# 常数锚(画布拟合探针):70/122/26/8 与 presentation/views/level_map_screen.gd 的 _content_size
# 公式(70 + maxl*122 + NODE_HALF.x + 8)同源复刻——改常数须两侧同步(本探针只读断言层,真源在屏)
var need_x2: float = 70.0 + maxl2 * 122.0 + 26.0 + 8.0
if float(ro2._canvas.custom_minimum_size.x) < need_x2 - 0.5:
_pre_fail("画布拟合不足(min.x=%.0f < 最深内容 %.0f——深层节点不可点回归)" % [ro2._canvas.custom_minimum_size.x, need_x2])
@@ -208,6 +205,15 @@ func _ready() -> void:
await get_tree().process_frame
if not ms.has_flag(flag_t):
_problems.append("case④ 核心旗态未落位")
# ---- case④b 旗纹理槽回归断言(P0-1 修复批 2026-10-02):T13 批曾在 paint 尾部无条件
# `u.tex_flag = null` 覆盖 T17 批的条件赋值=素材在位时插旗不可见(headless 缺盘走字形回退
# 掩盖)——有盘断言纹理在槽、缺盘断言字形回退,两分支防双写踩踏复活 ----
var flag_unit = bv._cells.get(flag_t)
if bv._flag_tex != null:
if flag_unit == null or flag_unit.tex_flag != bv._flag_tex:
_problems.append("case④b 旗纹理槽未挂封印旗纹理(tex_flag=%s)" % ["无单元" if flag_unit == null else str(flag_unit.tex_flag)])
elif flag_unit == null or flag_unit.glyph != "旗":
_problems.append("case④b 缺盘字形回退缺失(glyph=%s)" % ["无单元" if flag_unit == null else String(flag_unit.glyph)])
# ---- case⑤ 双击 chord:禁入格双左击(核心拒挖零副作用)→ chord_attempted ----
_push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT)
_push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT)
@@ -318,7 +324,7 @@ func _ready() -> void:
else:
_problems.append("case⑭ 无未开格可验(板面异常)")
if _problems.is_empty():
print("[ui_input_probe] PASS — 悬停金框/禁入信号/路径预览/flag_place/chord/dig/英雄落位/影子构成/朝向/缩格重排/缺位回退/变异角标 十四断言全绿(真实 push_input 路径——⑫图上命中⑬画布拟合 2026-09-28 增,⑭变异角标 2026-09-29 增)")
print("[ui_input_probe] PASS — 悬停金框/禁入信号/路径预览/flag_place/chord/dig/英雄落位/影子构成/朝向/缩格重排/缺位回退/变异角标 十五断言全绿(真实 push_input 路径——⑫图上命中⑬画布拟合 2026-09-28 增,⑭变异角标 2026-09-29 增,④b 旗纹理槽回归 2026-10-02 增)")
get_tree().quit(0)
else:
for p in _problems:
+6 -11
View File
@@ -4,22 +4,17 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_layout_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律)
for p0 in ["user://smoke_layout_probe_save.json", "user://smoke_layout_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso = load("res://core/save_manager.gd").new("user://smoke_layout_probe_save.json")
iso.load_from_disk()
iso.mark_first_adventure_done()
main.save_manager = iso
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
main._refresh_resume_button()
# 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_layout_probe_save", true, false, true)
main._start_adventure_from_menu()
for i in range(3):
await get_tree().process_frame
+8 -26
View File
@@ -7,10 +7,12 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
@@ -20,11 +22,10 @@ func _ready() -> void:
get_tree().quit(1)
return
# ---- 营地覆盖层(元进度实现批——主菜单入口/开合/余额文案/购买禁用态;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律)----
var iso = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json")
iso.load_from_disk()
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律;
# 营地先行段只换 meta 不换 save_manager——冒险段换注零档在前)----
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", false, false, false, false)
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix")
if campbtn == null:
problems.append("营地入口按钮缺失(开始层 campbtn)")
else:
@@ -52,15 +53,7 @@ func _ready() -> void:
if main.camp_overlay.visible or not main._start_overlay.visible:
problems.append("营地关闭后未恢复开始层(开合往返失守)")
# 换回全新零档(营地段购买态不泄入后续冒险段——确定性;先删后建防陈旧档泄入)
for p in ["user://smoke_ui_probe_camp.json", "user://smoke_ui_probe_camp.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso_zero = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json")
iso_zero.load_from_disk()
iso_zero.mark_first_adventure_done() # 对齐真实档游玩史口径(模板抽签路径不变)
main.save_manager = iso_zero # 迭代 8 卡①——断点写盘面同步隔离(禁碰真实档)
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_zero)
main._refresh_resume_button()
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", true, false, true)
main._start_adventure_from_menu()
await get_tree().process_frame
await get_tree().process_frame
@@ -244,14 +237,3 @@ func _ready() -> void:
push_error("[ui_probe] FAIL — " + p)
print("[ui_probe] FAIL — " + "; ".join(problems))
get_tree().quit(1)
# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;text 前缀匹配首枚按钮)
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root is Button and String(root.text).begins_with(prefix):
return root
for child in root.get_children():
var found := _find_button_by_prefix(child, prefix)
if found != null:
return found
return null
+9 -11
View File
@@ -6,10 +6,14 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_tune_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
const SIM_GUARD_FRAMES := 1800 # ⑤ N=10 headless 模拟防挂死帧预算上限(ADR-0004:探针内停滞检测第一道——坏态不等满预算才报,超时即断言失败)
func _ready() -> void:
await get_tree().process_frame
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
@@ -37,7 +41,7 @@ func _ready() -> void:
main.tune_panel.set_sliders(100, 0)
main.tune_panel.set_sim_count(10)
main.tune_panel.press_simulate_for_probe()
var guard := 1800 # N=10 headless 模拟防挂死上限(ADR-0004:探针内停滞检测第一道)
var guard := SIM_GUARD_FRAMES
while main.tune_panel.is_running() and guard > 0:
guard -= 1
await get_tree().process_frame
@@ -59,15 +63,9 @@ func _ready() -> void:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp])
# ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成"
# (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获
# 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律)
for p0 in ["user://smoke_tune_probe_save.json", "user://smoke_tune_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso_tune = load("res://core/save_manager.gd").new("user://smoke_tune_probe_save.json")
iso_tune.load_from_disk()
iso_tune.mark_first_adventure_done()
main.save_manager = iso_tune
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_tune)
# 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_tune_probe_save", true, false, false)
main.tune_panel.set_sliders(100, 50)
main.tune_panel.press_apply_for_probe()
main._start_adventure_from_menu() # 完整冒险路径(唯一试玩入口;_start_single 按语义清零滑条 = 恒基线,不承载加成断言)
+2 -2
View File
@@ -4,7 +4,7 @@
**Test Framework**: 双载体(ADR-0004)——`--headless --script` 逻辑冒烟 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态,正式 CI 期再议)
**CI**: `.github/workflows/tests.yml`(首次 push 到 GitHub 后自动生效;仓库 2026-09-12 尚未初始化 git)
**Setup date**: 2026-09-12
**参考实现**: `game/smoke/`(冒烟 96/96 + 三 UI 探针——正式工程的活样板)
**参考实现**: `game/smoke/`(冒烟 628+ 断言 + 九场景探针——正式工程的活样板)
## Directory Layout
@@ -26,7 +26,7 @@ godot --headless --script tests/run_all.gd # 统一入口(CI 调用
godot --headless --script tests/unit/example_test.gd
# 场景探针(双载体之二)——以探针场景为运行目标,走完整项目引导(autoload 在位)
godot --headless --path . res://tests/probes/<probe>.tscn
godot --headless --path . res://smoke/<probe>.tscn
# CI 旁必须配外部 wall-clock 超时(ADR-0004:引擎无死锁探测)
timeout 120s godot --headless --script tests/run_all.gd
+7 -8
View File
@@ -17,17 +17,16 @@
5. 移动:已开格瞬移、**已引爆雷格可通行可读**(工程债呈现半边)
6. 停手:RAP 1:1 转存、下轮 AP 硬重置同图继续
7. 轮末结算:明细逐行呈现、来源标注可读(P2——"这一行是我哪步扫雷带来的"可答)
8. 道具使用窗口:授予段后弹出、至多选一件、满血治疗包 usable_now 禁选态正确
9. 清图:清图奖励兑付 + 完美排雷高光(零误标时双份)+ 换新图;未刷新的停手轮不换图
10. 轮内刷新(方案 A):Active 当场换图、AP 保留、RAP 扣减、每轮 ≤2 次、跨图事实结算完整
8. 清图:清图奖励兑付 + 完美排雷高光(零误标时双份)+ 换新图;未刷新的停手轮不换图
9. 轮内刷新(方案 A):Active 当场换图、AP 保留、RAP 扣减、每轮 ≤2 次、跨图事实结算完整
## Battle & Level
11. 战斗段:主角先手攻击行(hero_attack 来源)、敌方行为环读怪(姿态↔下一拍)、死亡即不动手
12. 胜负判定与 battle_over 清理全序;库存跨战斗延续、关卡末"剩余道具 ×N 清空"反馈
13. 关卡推进:节点链兑付(奖励点/恢复点)、Boss 房(Dmax 行)、层间 HP 携带
10. 战斗段:主角先手攻击行(hero_attack 来源)、敌方行为环读怪(姿态↔下一拍)、死亡即不动手
11. 胜负判定与 battle_over 清理全序;aux 魔法道具关内跨战斗延续(三选一 BonusPickOverlay 入包、C 区槽位两步交互施用)、关卡末"剩余道具 ×N 清空"反馈(aux_cleared)
12. 关卡推进:节点链兑付(奖励点/恢复点)、Boss 房(Dmax 行)、层间 HP 携带
## Data & Perf
14. 数值调整板:**打开可见**(覆盖层层级回归——缺陷⑤)、滑条即时生效、批量模拟可跑
15. 无明显卡顿(60fps 目标);连续 5 分钟无内存异常增长;窗口/结算屏文案中文显示正常(CJK 字体核对)
13. 数值调整板:**打开可见**(覆盖层层级回归——缺陷⑤)、滑条即时生效、批量模拟可跑
14. 无明显卡顿(60fps 目标);连续 5 分钟无内存异常增长;窗口/结算屏文案中文显示正常(CJK 字体核对)
+1 -1
View File
@@ -3,4 +3,4 @@
每系统一个子目录(`reward_economy/`、`board_generator/`…)。协议与命名见 `tests/README.md`。
**活样板**:`example_test.gd`(本目录)——第一个真实测试请以它为模板。
**参考实现**:`game/smoke/smoke_all.gd`(96 条断言的组织方式)。
**参考实现**:`game/smoke/smoke_all.gd`(628+ 条断言的组织方式)。
@@ -16,15 +16,15 @@ const DamageControllerScript := preload("res://core/damage_controller.gd")
const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入)
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑)
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_five_recipes_and_replay(db)
_test_policy_immediate_mode(db)
@@ -33,21 +33,7 @@ func _init() -> void:
_test_hero_death_halt(db)
_test_ac_merge(db)
_test_pool_clamp(db)
if _failures > 0:
push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[damage_controller_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("damage_controller_test"))
# ---- 夹具 ----
@@ -84,7 +70,7 @@ func _run_recipe_sequence(db) -> Array:
var units = h.units
var ctrl = h.ctrl
if not h.spawn_ok:
_check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
Kit.check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
return []
var hero_before: int = int(units.hero_unit().hp)
var enemy_hp_before: Array = []
@@ -122,27 +108,27 @@ func _run_recipe_sequence(db) -> Array:
for id in [t1, t2, t3, t4, t5]:
if ctrl.outcome(int(id)) != "redeemed":
outcomes_redeemed = false
_check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
Kit.check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
var hero_after: int = int(units.hero_unit().hp)
var r2: Dictionary = _row_by_display_prefix("T2敌方技")
var r5: Dictionary = _row_by_display_prefix("T5反噬")
_check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
Kit.check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1)))
_check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
_check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
Kit.check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
Kit.check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
var r1: Dictionary = _row_by_display_prefix("T1主角攻击")
var ex1: Dictionary = r1.get("extra", {})
_check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
_check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces))
Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces))
var tier_ok := false
for dv in h.db.settlement.roll_tier_discounts:
if int(dv) == int(ex1.get("tier", -1)):
tier_ok = true
if int(ex1.get("tier", -1)) == 100:
tier_ok = true
_check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
Kit.check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
var r4: Dictionary = _row_by_display_prefix("T4群伤")
var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1))
@@ -158,10 +144,10 @@ func _run_recipe_sequence(db) -> Array:
var attack_landed: int = int(r1.get("value", 0))
var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0))
if any_enemy_dead:
_check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
Kit.check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast))
else:
_check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
Kit.check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast)
var protocol_ok := true
@@ -174,7 +160,7 @@ func _run_recipe_sequence(db) -> Array:
protocol_ok = false
if not (r.extra is Dictionary) or not r.extra.has("target"):
protocol_ok = false
_check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
Kit.check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
# 复现序列(供双跑对拍):逐行 (display, value)
var seq: Array = []
@@ -185,9 +171,9 @@ func _run_recipe_sequence(db) -> Array:
func _test_five_recipes_and_replay(db) -> void:
var run1: Array = _run_recipe_sequence(db)
_check("五配方序列产出 5 行伤害行", run1.size() == 5)
Kit.check("五配方序列产出 5 行伤害行", run1.size() == 5)
var run2: Array = _run_recipe_sequence(db)
_check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
Kit.check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
# ---- A 模式(开单即结——回切位) ----
@@ -201,8 +187,8 @@ func _test_policy_immediate_mode(db) -> void:
var floor_v: int = int(h.db.settlement.damage_floor)
var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
"A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1))
_check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
_check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ----
@@ -221,8 +207,8 @@ func _test_source_death_void(db) -> void:
var t: int = h.ctrl.submit(ticket)
h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废
h.ctrl.flush()
_check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
_check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
Kit.check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
Kit.check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ----
@@ -238,7 +224,7 @@ func _test_requires_gate_and_zero(db) -> void:
var big: int = front_hp * 10
var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
"清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1))
_check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
Kit.check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
"空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1))
var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack",
@@ -246,8 +232,8 @@ func _test_requires_gate_and_zero(db) -> void:
gated_ticket["requires"] = t_zero
var t_gated: int = h.ctrl.submit(gated_ticket)
h.ctrl.flush()
_check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
_check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
Kit.check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
Kit.check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
# ---- 主角倒下:flush 余单全部作废(_halted 终止) ----
@@ -263,8 +249,8 @@ func _test_hero_death_halt(db) -> void:
var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
"余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2))
h.ctrl.flush()
_check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
_check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
Kit.check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
Kit.check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
# ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ----
@@ -275,10 +261,10 @@ func _test_ac_merge(db) -> void:
if not h.spawn_ok:
return
var base_ac: int = int(h.db.hero.armor_class)
_check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
var override_v: int = base_ac + int(h.db.settlement.damage_floor)
h.ctrl.set_hero_ac_override(override_v)
_check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
# ac 型状态效果叠其上(找 hero 侧 unit=ac 行)
var ac_eff := {}
for row in h.db.status_effects.rows:
@@ -286,12 +272,12 @@ func _test_ac_merge(db) -> void:
ac_eff = row
break
if ac_eff.is_empty():
_check("前置:状态表存在 hero 侧 ac 型行", false)
Kit.check("前置:状态表存在 hero 侧 ac 型行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
h.effects.apply_effect(String(ac_eff.effect_id), src)
var expected: int = override_v + int(ac_eff.magnitude)
_check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
Kit.check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
# ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ----
@@ -303,7 +289,7 @@ func _test_pool_clamp(db) -> void:
return
var lo: int = int(h.db.settlement.pool_clamp_lo)
var hi: int = int(h.db.settlement.pool_clamp_hi)
_check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
# 找 hero_offense 池的 percent 型 hero 侧效果行
var pool_eff := {}
for row in h.db.status_effects.rows:
@@ -313,14 +299,14 @@ func _test_pool_clamp(db) -> void:
pool_eff = row
break
if pool_eff.is_empty():
_check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
Kit.check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
var expected1: int = clampi(int(pool_eff.magnitude), lo, hi)
_check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
_check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变",
Kit.check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变",
second_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
# ac 型效果不入 percent 池
var ac_eff2 := {}
@@ -331,7 +317,7 @@ func _test_pool_clamp(db) -> void:
if not ac_eff2.is_empty():
var before: int = h.ctrl.clamped_pool("hero_offense")
h.effects.apply_effect(String(ac_eff2.effect_id), src)
_check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
# stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入)
var stack_eff := {}
for row3 in h.db.status_effects.rows:
@@ -339,7 +325,7 @@ func _test_pool_clamp(db) -> void:
stack_eff = row3
break
if stack_eff.is_empty():
_check("前置:状态表存在 stack 政策行", false)
Kit.check("前置:状态表存在 stack 政策行", false)
return
var effects2 = StatusContainerScript.new(h.db)
effects2.notify_battle_start()
@@ -348,4 +334,4 @@ func _test_pool_clamp(db) -> void:
for i in range(max_n + 1):
if effects2.apply_effect(String(stack_eff.effect_id), src):
granted += 1
_check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
@@ -6,36 +6,21 @@ extends SceneTree
const DamageMathScript := preload("res://core/damage_math.gd")
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
var _failures := 0
var _checks := 0
var db
func _init() -> void:
db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_pool_damage()
_test_counter_component()
_test_roll_discount_percent()
_test_rolled_component()
if _failures > 0:
push_error("[damage_math_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[damage_math_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("damage_math_test"))
func _floor_val() -> int:
@@ -46,23 +31,23 @@ func _floor_val() -> int:
func _test_pool_damage() -> void:
var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值)
_check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
_check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d)
_check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d)
_check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
_check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
_check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0)
Kit.check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d)
Kit.check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d)
Kit.check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
Kit.check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0)
# ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)----
func _test_counter_component() -> void:
var floor_v := _floor_val()
_check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0
Kit.check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0
and DamageMathScript.counter_component(-1, 5, floor_v) == 0)
_check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2)
_check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v)
_check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0)
Kit.check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2)
Kit.check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v)
Kit.check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0)
# ---- 掷点折扣查档(纯函数):满阈值恒全额;档位边界/末档语义与表数组一致 ----
@@ -71,22 +56,22 @@ func _test_roll_discount_percent() -> void:
var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表)
var bounds: Array = db.settlement.roll_tier_bounds
var discounts: Array = db.settlement.roll_tier_discounts
_check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
_check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
Kit.check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
Kit.check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100)
# 缺口 = 1:落首档(缺口 ∈ [1, bounds[0]] → discounts[0])
var tier0_expected: int = int(discounts[0])
if int(bounds[0]) >= 1:
_check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected)
Kit.check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected)
# 缺口超末边界:落末档
var last_expected: int = int(discounts[-1])
_check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected)
Kit.check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected)
# 档位值降序(重档折扣更深)——表自检
var descending := true
for i in range(discounts.size() - 1):
if int(discounts[i]) < int(discounts[i + 1]):
descending = false
_check("档位值随档位加深不升(折扣单调)", descending)
Kit.check("档位值随档位加深不升(折扣单调)", descending)
# ---- 吃掷点分量算术:折扣→(暴击)→floor→+克制修正(单一取整链)----
@@ -94,9 +79,9 @@ func _test_roll_discount_percent() -> void:
func _test_rolled_component() -> void:
var base := float(_floor_val() + 7) # 表值推导探针基线
var crit_mult: int = int(db.settlement.crit_multiplier)
_check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
_check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
_check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
_check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val())
_check("复合序 = 基线×折扣%×暴击%(floor 只在链末)",
Kit.check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
Kit.check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
Kit.check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
Kit.check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val())
Kit.check("复合序 = 基线×折扣%×暴击%(floor 只在链末)",
DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0)))
@@ -12,35 +12,19 @@ const ConfigDBScript := preload("res://core/config_db.gd")
const RngServiceScript := preload("res://core/rng_service.gd")
const StatusContainerScript := preload("res://core/status_container.gd")
const DamageControllerScript := preload("res://core/damage_controller.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_damage_controller_vocabularies()
_test_rng_tag_closure(db)
_test_ticket_nine_columns()
_test_status_boolean_acceptance(db)
if _failures > 0:
push_error("[system_contracts_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[system_contracts_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("system_contracts_test"))
# ---- B1 兑付配方词表:五配方/三目标规则/双结账政策——常量非空且互异(封闭枚举负空间) ----
@@ -55,11 +39,11 @@ func _test_damage_controller_vocabularies() -> void:
for k in kinds:
if String(k).is_empty():
nonempty = false
_check("五配方常量非空(开单面唯一分派键)", nonempty)
Kit.check("五配方常量非空(开单面唯一分派键)", nonempty)
var unique_kinds: Dictionary = {}
for k in kinds:
unique_kinds[String(k)] = true
_check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size())
Kit.check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size())
var targets: Array = [
DamageControllerScript.TARGET_FRONT_ALIVE, DamageControllerScript.TARGET_HERO,
DamageControllerScript.TARGET_ALL_ALIVE,
@@ -67,8 +51,8 @@ func _test_damage_controller_vocabularies() -> void:
var unique_targets: Dictionary = {}
for t in targets:
unique_targets[String(t)] = true
_check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
_check("双结账政策常量互异(A 即结 / B 攒批回切位)",
Kit.check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
Kit.check("双结账政策常量互异(A 即结 / B 攒批回切位)",
String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT))
@@ -76,9 +60,9 @@ func _test_damage_controller_vocabularies() -> void:
func _test_rng_tag_closure(db) -> void:
var stream_label: String = String(db.settlement.armor_roll_stream)
_check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
RngServiceScript.PURPOSE_TAGS.has(stream_label))
_check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
RngServiceScript.PURPOSE_TAGS.size() > 0)
@@ -95,9 +79,9 @@ func _test_ticket_nine_columns() -> void:
for c in columns:
if not t.has(c):
all_present = false
_check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
_check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
_check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
Kit.check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
Kit.check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
Kit.check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
func _bare_controller():
@@ -129,18 +113,18 @@ func _test_status_boolean_acceptance(db) -> void:
var any_row: Dictionary = {}
if db.status_effects.rows.size() > 0:
any_row = db.status_effects.rows[0]
_check("前置:状态表非空", not any_row.is_empty())
Kit.check("前置:状态表非空", not any_row.is_empty())
if any_row.is_empty():
return
var probe_id: String = String(any_row.effect_id)
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
_check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false)
_check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false)
Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false)
Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false)
effects.notify_battle_start()
_check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true)
Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true)
var active: Array = effects.query_active(String(any_row.target_side))
var projected := false
for a in active:
if String(a.effect_id) == probe_id:
projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude)
_check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
Kit.check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
+15 -27
View File
@@ -6,13 +6,7 @@
extends SceneTree
const AuxItemsScript := preload("res://core/aux_items.gd")
var _problems: Array = []
func check(cond: bool, msg: String) -> void:
if not cond:
_problems.append(msg)
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道)
class StubTable:
@@ -59,39 +53,39 @@ func _init() -> void:
var inv = _new_inv()
var r1: Dictionary = inv.add_item("sapper_order", "safe_cell", Vector2i(3, 4), 2)
var r2: Dictionary = inv.add_item("nest_pendulum", "flag_hit", Vector2i(5, 6), 3)
check(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)")
check(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2,
Kit.check_quiet(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)")
Kit.check_quiet(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2,
"授予来源可归因(source/cell/round)")
var inv_proj: Array = inv.query_inventory(Callable())
check(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用")
check(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执")
check(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)")
check(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)")
Kit.check_quiet(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用")
Kit.check_quiet(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执")
Kit.check_quiet(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)")
Kit.check_quiet(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)")
# ② 真视恒可用+边缘窗口∩板(3×3 中心贴角——角 4 格)
var v_ok: Dictionary = inv.validate_use(int(r2.handle), Vector2i(0, 0), Vector2i(10, 10), Callable())
check(bool(v_ok.ok) and v_ok.resolved.size() == 4,
Kit.check_quiet(bool(v_ok.ok) and v_ok.resolved.size() == 4,
"真视边缘窗口=窗口∩板(角 4 格——护栏只缩不扩)")
# 预读点击级零信息:中心板内但回调恒 false(全已开)→拒绝零消耗
var inv2 = _new_inv()
var rp: Dictionary = inv2.add_item("loot_compass", "safe_cell", Vector2i(1, 1), 1)
var v_zero: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(5, 5), Vector2i(10, 10), Callable())
check(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1,
Kit.check_quiet(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1,
"预读点击级零信息拒绝(不消耗——库存不变)")
# 目标越板
var v_oob: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(12, 5), Vector2i(10, 10), Callable())
check(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝")
Kit.check_quiet(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝")
# ③ 叠加层+三清(retire 不动库存;reset 全清)
inv2.apply_overlay("mine_reveal", v_ok.resolved)
inv2.apply_overlay("reward_preview", [Vector2i(5, 5)])
var ov: Dictionary = inv2.query_overlays()
check(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)")
Kit.check_quiet(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)")
inv2.retire_overlays()
check(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1,
Kit.check_quiet(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1,
"换图三清:叠加层消亡+库存不动(关卡域)")
inv2.reset()
check(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)")
Kit.check_quiet(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)")
# ④ 零随机重放:同操作序列两遍,投影逐位相同
var replay_a: Array = []
@@ -106,13 +100,7 @@ func _init() -> void:
replay_a = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()]
else:
replay_b = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()]
check(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同")
Kit.check_quiet(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同")
# ---- 汇总 ----
if _problems.is_empty():
print("[aux_items_test] PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过")
quit(0)
else:
for p in _problems:
push_error("[aux_items_test] FAIL — " + p)
quit(1)
quit(Kit.finish("aux_items_test", "PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过"))
+42 -54
View File
@@ -8,16 +8,11 @@ extends SceneTree
const SaveManagerScript := preload("res://core/save_manager.gd")
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道)
var _problems: Array = []
var _bp_path := "user://test_breakpoint_resume.json"
func check(cond: bool, msg: String) -> void:
if not cond:
_problems.append(msg)
func _init() -> void:
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", false):
@@ -32,64 +27,64 @@ func _init() -> void:
var adr_a = AdventureRunnerScript.new(db, rng_a)
adr_a.set_save_manager(sm_a)
var params := {"hero_override": {}, "aux_unlocked": {}}
check(adr_a.start_adventure(1234, params), "冒险启动(入口停点)")
check(sm_a.has_breakpoint(), "入口停点断点在档")
check(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1")
check(adr_a.abandon(), "入口停点放弃受理(非战斗期)")
check(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)")
Kit.check_quiet(adr_a.start_adventure(1234, params), "冒险启动(入口停点)")
Kit.check_quiet(sm_a.has_breakpoint(), "入口停点断点在档")
Kit.check_quiet(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1")
Kit.check_quiet(adr_a.abandon(), "入口停点放弃受理(非战斗期)")
Kit.check_quiet(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)")
var sm_reload = SaveManagerScript.new(_bp_path)
sm_reload.load_from_disk()
check(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)")
Kit.check_quiet(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)")
# ---- Part 2:中断基线——入口停点后选路进战,记板指纹与计数器 ----
var rng_b = load("res://core/rng_service.gd").new()
var sm_b = SaveManagerScript.new(_bp_path)
var adr_b = AdventureRunnerScript.new(db, rng_b)
adr_b.set_save_manager(sm_b)
check(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)")
Kit.check_quiet(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)")
var runner_b = adr_b.runner()
var c0: int = rng_b.counter()
var cand_b: Array = runner_b.next_candidate_ids()
var hp0: int = runner_b.hero_hp
var bp_disk: Dictionary = sm_b.breakpoint_snapshot()
check(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)")
check(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战")
Kit.check_quiet(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)")
Kit.check_quiet(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战")
var battle_b = runner_b.current_battle()
check(battle_b != null, "基线首战开打")
Kit.check_quiet(battle_b != null, "基线首战开打")
var fp_b := _board_fingerprint(battle_b.minesweeper.current_board())
var c1: int = rng_b.counter()
# ---- Part 3:续玩确定性——新进程对象自盘上断点重建,同种子续玩板逐位一致 ----
var sm_c = SaveManagerScript.new(_bp_path)
check(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档")
Kit.check_quiet(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档")
var bp_c: Dictionary = sm_c.breakpoint_snapshot()
var rng_c = load("res://core/rng_service.gd").new()
var adr_c = AdventureRunnerScript.new(db, rng_c)
check(adr_c.resume_adventure(bp_c, params), "续玩受理")
Kit.check_quiet(adr_c.resume_adventure(bp_c, params), "续玩受理")
var runner_c = adr_c.runner()
check(int(adr_c.master_seed) == 1234, "续玩主种子回填")
check(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)")
check(runner_c.hero_hp == hp0, "续玩生命账本回填")
Kit.check_quiet(int(adr_c.master_seed) == 1234, "续玩主种子回填")
Kit.check_quiet(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)")
Kit.check_quiet(runner_c.hero_hp == hp0, "续玩生命账本回填")
var cand_c: Array = runner_c.next_candidate_ids()
check(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)")
check(runner_c.awaiting_choice(), "续玩处选路停点态")
check(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战")
Kit.check_quiet(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)")
Kit.check_quiet(runner_c.awaiting_choice(), "续玩处选路停点态")
Kit.check_quiet(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战")
var battle_c = runner_c.current_battle()
var fp_c := _board_fingerprint(battle_c.minesweeper.current_board())
check(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)")
check(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致")
Kit.check_quiet(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)")
Kit.check_quiet(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致")
# ---- Part 4:续玩幂等——同一断点二次续玩(新进程对象)结果逐位同 ----
var sm_d = SaveManagerScript.new(_bp_path)
sm_d.load_from_disk()
var rng_d = load("res://core/rng_service.gd").new()
var adr_d = AdventureRunnerScript.new(db, rng_d)
check(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理")
Kit.check_quiet(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理")
var runner_d = adr_d.runner()
check(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)")
check(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路")
Kit.check_quiet(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)")
Kit.check_quiet(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路")
var battle_d = runner_d.current_battle()
check(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)")
Kit.check_quiet(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)")
# ---- Part 5:三选一停点中断→续玩(迭代 10 奖励重构——候选不重抽、已兑付不重付、次数计数一致)----
var rng_e = load("res://core/rng_service.gd").new()
@@ -97,50 +92,43 @@ func _init() -> void:
var adr_e = AdventureRunnerScript.new(db, rng_e)
adr_e.set_save_manager(sm_e)
var params_e := {"hero_override": {}, "aux_unlocked": {"blast_kit": true}}
check(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)")
Kit.check_quiet(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)")
adr_e._bonus_taken["attack"] = 1 # 白盒预置计数(断点附随件往返的判据面——计数不可由加算区反推)
adr_e._on_level_finished({"result": "victory", "hp": 30, "node_index": 1, "node_id": 1, "node_type": "shop"})
var cands_e: Array = (adr_e._bonus_pending.get("candidates", []) as Array).duplicate()
check(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)")
Kit.check_quiet(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)")
var sm_f = SaveManagerScript.new(_bp_path)
check(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)")
Kit.check_quiet(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)")
var bp_f: Dictionary = sm_f.breakpoint_snapshot()
var bp_f_cands: Array = (bp_f.get("bonus_pending", {}) as Dictionary).get("candidates", []) as Array
check(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)")
check(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)")
Kit.check_quiet(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)")
Kit.check_quiet(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)")
var got_cands := {"cands": [] as Array}
var adr_f = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
adr_f.set_save_manager(sm_f) # 兑付后停点快照落盘承载(与真机组合根同构)
adr_f.bonus_choice_presented.connect(func(p): got_cands["cands"] = (p.get("candidates", []) as Array).duplicate())
check(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)")
check(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)")
check(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)")
check(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)")
check(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)")
check(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理")
Kit.check_quiet(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)")
Kit.check_quiet(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)")
Kit.check_quiet(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)")
Kit.check_quiet(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)")
Kit.check_quiet(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)")
Kit.check_quiet(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理")
var picked_e: String = String(adr_f._bonus_result.get("picked", ""))
if adr_f._bonus_taken.has(picked_e):
check(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1),
Kit.check_quiet(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1),
"P5 兑付计数一致(%s——预置 1 + 本次兑付)" % picked_e)
check(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)")
Kit.check_quiet(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)")
var bp_g: Dictionary = sm_f.breakpoint_snapshot()
var adr_g = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
adr_g.set_save_manager(sm_f) # 已兑付态续玩的后续快照同承载
var got_result := {"r": {}}
adr_g.bonus_resolved.connect(func(r): got_result["r"] = r.duplicate())
check(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理")
check(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)")
check(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)")
Kit.check_quiet(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理")
Kit.check_quiet(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)")
Kit.check_quiet(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)")
_clear_disk()
if _problems.is_empty():
print("[breakpoint_resume_test] PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿")
quit(0)
else:
for p in _problems:
push_error("[breakpoint_resume_test] FAIL — " + p)
print("[breakpoint_resume_test] FAIL — " + p)
quit(1)
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿"))
func _board_fingerprint(b) -> String:
@@ -7,27 +7,12 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables)
const SRC_DIR := "res://data/tables"
const BAD_DIR := "user://aux_fixture_target_violation"
func _copy_tables() -> bool:
DirAccess.make_dir_recursive_absolute(BAD_DIR)
var dir := DirAccess.open(SRC_DIR)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
var err := dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f)
if err != OK:
return false
f = dir.get_next()
return true
func _inject_violation() -> bool:
var path := BAD_DIR + "/aux_items.tres"
var f := FileAccess.open(path, FileAccess.READ)
@@ -58,7 +43,7 @@ func _inject_violation() -> bool:
func _init() -> void:
if not _copy_tables() or not _inject_violation():
if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation():
push_error("[aux_target_violation_test] 夹具目录准备失败")
quit(2)
return
+28 -44
View File
@@ -5,16 +5,14 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
_check("is_ready 通道随加载置位", db.is_ready)
Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
Kit.check("is_ready 通道随加载置位", db.is_ready)
if not load_ok:
for d in db.diagnostics():
if d.severity == "error":
@@ -25,21 +23,7 @@ func _init() -> void:
_test_shop_accessors(db)
_test_meta_accessors(db)
_test_manifest_dir_sync(db)
if _failures > 0:
push_error("[config_db_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[config_db_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("config_db_test"))
# ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)----
@@ -67,34 +51,34 @@ func _test_progress_resolution(db) -> void:
# 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等)
var probe_row: Dictionary = bg_rows[0]
var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth))
_check("进度解析精确命中返回原行(board_gen 首行)",
Kit.check("进度解析精确命中返回原行(board_gen 首行)",
int(got.get("level_index", -99)) == int(probe_row.level_index)
and int(got.get("node_depth", -99)) == int(probe_row.node_depth)
and String(got.get("board_type", "")) == String(probe_row.get("board_type", "")))
# enemy 行集同通道:同 (L,D) 精确行存在(关卡节点地图 前置——battle 节点双表精确行契约)
var en_got: Dictionary = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth))
_check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
Kit.check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
# 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重)
db.reset_diagnostics()
var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
_check("深度越最大行 clamp 取该关最大深度行",
Kit.check("深度越最大行 clamp 取该关最大深度行",
int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1]))
_check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
var found_clamp := false
for d in db.diagnostics():
if d.category == "resolve_depth_clamp":
found_clamp = true
_check("警告类别 = resolve_depth_clamp", found_clamp)
Kit.check("警告类别 = resolve_depth_clamp", found_clamp)
var warn_before: int = db.diagnostics_count("warning")
db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
_check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
# 关卡 clamp:请求关卡超最大行 → 取最大关卡行
db.reset_diagnostics()
var high: Dictionary = db.resolve_board_gen(max_l + 1, 0)
_check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
# ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配)----
@@ -114,12 +98,12 @@ func _test_variant_pool_gate(db) -> void:
var tag: String = String(gated_row.board_types[0])
var before: Array = db.variant_pool(u - 1, tag)
var after: Array = db.variant_pool(u, tag)
_check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
_check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
if not wildcard_row.is_empty():
var u2: int = int(wildcard_row.unlock_level)
var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag")
_check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
func _rows_contain(pool: Array, row: Dictionary) -> bool:
@@ -134,26 +118,26 @@ func _rows_contain(pool: Array, row: Dictionary) -> bool:
func _test_shop_accessors(db) -> void:
var products: Array = db.shop_products()
if products.is_empty():
_check("ShopTable products 行族非空(表已落籍前提)", false)
Kit.check("ShopTable products 行族非空(表已落籍前提)", false)
return
_check("ShopTable products 行族非空", true)
Kit.check("ShopTable products 行族非空", true)
var first: Dictionary = products[0]
var got: Dictionary = db.shop_product_row(String(first.product_id))
_check("shop_product_row 按 product_id 精确命中首行", got == first)
_check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
Kit.check("shop_product_row 按 product_id 精确命中首行", got == first)
Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
var tier_rows: Array = db._shop_rows("gp_drops")
if not tier_rows.is_empty():
var tier0: String = String(tier_rows[0].tier)
_check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
var bonus_rows: Array = db._shop_rows("level_gp_bonus")
if not bonus_rows.is_empty():
var blv: int = int(bonus_rows[0].level_index)
_check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
_check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
var price_rows: Array = db._shop_rows("price_level_bonus")
if not price_rows.is_empty():
var plv: int = int(price_rows[0].level_index)
_check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
# ---- MetaTable 消费面 ----
@@ -161,19 +145,19 @@ func _test_shop_accessors(db) -> void:
func _test_meta_accessors(db) -> void:
var forge_rows: Array = db._meta_rows("forge_products")
if forge_rows.is_empty():
_check("MetaTable forge_products 行族非空", false)
Kit.check("MetaTable forge_products 行族非空", false)
else:
var attr0: String = String(forge_rows[0].attr)
_check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0])
_check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty())
Kit.check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0])
Kit.check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty())
var key_rows: Array = db._meta_rows("boss_keystone")
if not key_rows.is_empty():
var klv: int = int(key_rows[0].level_index)
_check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
var conv_rows: Array = db._meta_rows("conversion")
if not conv_rows.is_empty():
var cattr: String = String(conv_rows[0].attr)
_check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
# ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)----
@@ -195,4 +179,4 @@ func _test_manifest_dir_sync(db) -> void:
for key in manifest.keys():
if not disk_files.has(String(manifest[key])):
synced = false
_check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
Kit.check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
@@ -7,26 +7,12 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables)
const SRC_DIR := "res://data/tables"
const BAD_DIR := "user://aux_fixture_dangling_aux"
func _copy_tables() -> bool:
DirAccess.make_dir_recursive_absolute(BAD_DIR)
var dir := DirAccess.open(SRC_DIR)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
if dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) != OK:
return false
f = dir.get_next()
return true
func _inject_violation() -> bool:
var path := BAD_DIR + "/reward.tres"
var f := FileAccess.open(path, FileAccess.READ)
@@ -50,7 +36,7 @@ func _inject_violation() -> bool:
func _init() -> void:
if not _copy_tables() or not _inject_violation():
if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation():
push_error("[reward_dangling_aux_test] 夹具目录准备失败")
quit(2)
return
@@ -6,33 +6,17 @@
extends SceneTree
const RngServiceScript := preload("res://core/rng_service.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
const TEST_SEED := 20260928 # 夹具种子(任意固定值;语义 = 可复现性,非玩法数值)
var _failures := 0
var _checks := 0
func _init() -> void:
_test_derive_determinism()
_test_counter_ledger()
_test_stream_draw_determinism()
_test_negative_seed_normalization()
if _failures > 0:
push_error("[rng_service_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[rng_service_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("rng_service_test"))
func _fresh_service():
@@ -49,11 +33,11 @@ func _test_derive_determinism() -> void:
for scope in range(8):
if a.derive_at(scope) != b.derive_at(scope):
same = false
_check("同种子两实例 derive_at(0..7) 逐位一致", same)
Kit.check("同种子两实例 derive_at(0..7) 逐位一致", same)
var a2 = _fresh_service()
var before: int = a2.counter()
a2.derive_at(0)
_check("derive_at 为纯函数不推计数器", a2.counter() == before)
Kit.check("derive_at 为纯函数不推计数器", a2.counter() == before)
# 全局取数计数器:唯一账本——open_stream 逐次 +1(scope 单调)
@@ -64,13 +48,13 @@ func _test_counter_ledger() -> void:
var c1: int = rng.counter()
rng.open_stream("armor_roll")
var c2: int = rng.counter()
_check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2)
Kit.check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2)
# 用途标签只进事件日志不参与派生——同 scope 同派生种子(ADR-0007 裁丙口径)
var rng_x = _fresh_service()
var rng_y = _fresh_service()
var s1: int = int(rng_x.open_stream("board_gen").seed)
var s2: int = int(rng_y.open_stream("reward_roll").seed)
_check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2)
Kit.check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2)
# 同种子两实例同标签流的实际取数序列逐位一致(回放确定性契约——存档快照=主种子+计数器的前提)
@@ -84,7 +68,7 @@ func _test_stream_draw_determinism() -> void:
var rb: int = b.open_stream("armor_roll").randi_range(1, faces)
if ra != rb:
same = false
_check("同种子两实例同标签流取数 12 次逐位一致", same)
Kit.check("同种子两实例同标签流取数 12 次逐位一致", same)
# 负输入 abs 归一化(警告通道)——|−s| 与 s 同派生
@@ -92,4 +76,4 @@ func _test_negative_seed_normalization() -> void:
var pos = _fresh_service()
var neg = RngServiceScript.new()
neg.set_master_seed(-TEST_SEED)
_check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0))
Kit.check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0))
+5 -16
View File
@@ -7,23 +7,12 @@
# 第一个真实测试:复制本文件为 tests/unit/<system>/<system>_<feature>_test.gd 并替换占位断言
extends SceneTree
func _init() -> void:
var failures := 0
failures += _check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol())
failures += _check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed())
if failures > 0:
push_error("[example_test] %d 项失败" % failures)
quit(1)
return
print("[example_test] all green")
quit(0)
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点
func _check(desc: String, ok: bool) -> int:
if not ok:
push_error("[example_test] FAIL: " + desc)
return 1
print("[example_test] PASS: " + desc)
return 0
func _init() -> void:
Kit.check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol())
Kit.check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed())
quit(Kit.finish("example_test"))
func _test_headless_protocol() -> bool:
# 占位断言:验证脚本本身在 SceneTree 上下文可执行。
@@ -3,6 +3,17 @@ extends SceneTree
# 原守 node_overlay.show_reward_items;奖励节点三选一与通关三选一合并共用 BonusPickOverlay 后,
# 本套件改守共用面的「功能存在 ≠ 功能可达」盲区:选择外发/强制确认两段式/替换子面板两路/结果态。
# 运行:godot --headless --path game --script res://tests/unit/presentation/reward_overlay_test.gd(expect 0)
# T6 收口批:断言走 test_kit 计数(Kit.check)+ 失败即停早退协议保留(顺序状态机——坏态下后继断言无效)。
const Kit := preload("res://tests/unit/test_kit.gd")
# 断言 + 失败即停(原直写 print FAIL + quit(1) + return 样板单点化——早退语义逐位保持)
func _req(desc: String, ok: bool) -> bool:
Kit.check(desc, ok)
if not ok:
quit(1)
return false
return true
func _initialize() -> void:
call_deferred("_run")
@@ -25,17 +36,15 @@ func _run() -> void:
for c in ov._choice_box.get_children():
if c is Button:
btns += 1
if ov.visible != true or btns != 3 or ov._confirm_btn.visible or ov._continue_btn.visible:
print("[reward_verify] FAIL — 选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns)
quit(1)
if not _req("选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns,
ov.visible and btns == 3 and not ov._confirm_btn.visible and not ov._continue_btn.visible):
return
# ② 点选→高亮→确认外发(真实按钮路径);确认前改选以最后点选为准
ov.press_card(0)
ov.press_card(1)
ov.press_confirm()
if ov._confirm_btn.visible != true or picked.size() != 1 or String(picked[0]) != "gp":
print("[reward_verify] FAIL — 点选确认未外发 pick_confirmed(gp)(%s)" % str(picked))
quit(1)
if not _req("点选确认未外发 pick_confirmed(gp)(%s)" % str(picked),
ov._confirm_btn.visible and picked.size() == 1 and String(picked[0]) == "gp"):
return
# ③ 替换子面板:丢旧收新与放弃新件两路外发(通关渠道=携带序号 / 节点渠道=库存句柄——tag 直通)
ov.open_replace([{"index": 0, "item_id": "probe_dust", "display_name": "显影尘"}], "爆破工兵包", 1, 1)
@@ -43,33 +52,26 @@ func _run() -> void:
for c in ov._replace_box.get_children():
if c is Button:
replace_btns += 1
if replace_btns != 2 or ov._choice_box.visible:
print("[reward_verify] FAIL — 替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns)
quit(1)
if not _req("替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns,
replace_btns == 2 and not ov._choice_box.visible):
return
var replaced_first := false
for c in ov._replace_box.get_children():
if c is Button:
c.pressed.emit()
replaced_first = true
break
if replaced.size() != 1 or int(replaced[0]) != 0:
print("[reward_verify] FAIL — 丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced))
quit(1)
if not _req("丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced),
replaced_first and replaced.size() == 1 and int(replaced[0]) == 0):
return
# ④ 结果态:继续钮点亮、唯一出口
ov.show_result("已选择:攻击力提升 · 攻击 +1")
if ov._continue_btn.visible != true or ov._replace_box.visible:
print("[reward_verify] FAIL — 结果态应点亮继续且收替换面板")
quit(1)
if not _req("结果态应点亮继续且收替换面板", ov._continue_btn.visible and not ov._replace_box.visible):
return
ov.press_continue()
if continued.size() != 1:
print("[reward_verify] FAIL — 继续未外发 continue_pressed")
quit(1)
if not _req("继续未外发 continue_pressed", continued.size() == 1):
return
ov.close_pick()
if ov.visible:
print("[reward_verify] FAIL — close_pick 应收屏")
quit(1)
if not _req("close_pick 应收屏", not ov.visible):
return
print("[reward_verify] PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过")
quit(0)
quit(Kit.finish("reward_verify", "PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过"))
@@ -9,9 +9,7 @@ extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const BoardViewScript := preload("res://presentation/views/board_view.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
@@ -23,7 +21,7 @@ func _init() -> void:
return
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if not load_ok:
quit(1)
return
@@ -33,25 +31,11 @@ func _init() -> void:
var bt := String(row.get("board_type", ""))
if bt != "" and not types.has(bt):
types.append(bt)
_check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0)
Kit.check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0)
_test_set_completeness(board_dir, types)
_test_hue_domains(board_dir)
_test_digit_ink_modes(board_dir, types)
if _failures > 0:
push_error("[terrain_tile_sets_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[terrain_tile_sets_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("terrain_tile_sets_test"))
# 套完整性:词表每个 board_type 的 16 墙 + 5 地面变体全部在盘(缺位 → BoardView 整批回落 dungeon 的
@@ -69,7 +53,7 @@ func _test_set_completeness(board_dir: String, types: Array) -> void:
var pf := board_dir.path_join("terrain_floor_%s_%d.png" % [String(bt), i])
if not FileAccess.file_exists(pf):
missing.append("floor_%d" % i)
_check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty())
Kit.check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty())
# 色相域抽检:wall_center 上半幅(墙顶面)与 floor_1 全幅的均值色相/明度
@@ -85,17 +69,17 @@ func _test_hue_domains(board_dir: String) -> void:
var wall := _load_stats(board_dir, "terrain_wall_center_%s.png" % String(bt), true)
var floor_img := _load_stats(board_dir, "terrain_floor_%s_1.png" % String(bt), false)
if wall.is_empty() or floor_img.is_empty():
_check("图型 %s 抽检样本可读" % String(bt), false)
Kit.check("图型 %s 抽检样本可读" % String(bt), false)
continue
wall_v[bt] = float(wall["v"])
floor_v[bt] = float(floor_img["v"])
var band: Array = bands[bt]
_check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])],
Kit.check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])],
float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1]))
_check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]],
Kit.check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]],
wall_v[bt] > floor_v[bt])
if wall_v.has("underdark") and wall_v.has("deep_underdark"):
_check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"]))
Kit.check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"]))
# 均值读数:hue 取饱和度 > 0.05 的像素(墨线/灰黑不影响色相均值);v 取全幅均值
@@ -150,26 +134,26 @@ func _test_digit_ink_modes(board_dir: String, types: Array) -> void:
var tex: Texture2D = ImageTexture.create_from_image(Image.load_from_file(p))
BoardViewScript._normalize_floor_luma(tex, 0.10, lumas)
if not readable:
_check("图型 %s 数字墨族断言样本可读" % String(bt), false)
Kit.check("图型 %s 数字墨族断言样本可读" % String(bt), false)
continue
var mode: Dictionary = BoardViewScript._digit_ink_mode(lumas)
var plate: bool = bool(mode["plate"])
var dark: bool = bool(mode["dark"])
var mode_name := "色牌垫底" if plate else ("深墨族直画" if dark else "亮墨族直画")
if plate:
_check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [
Kit.check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [
String(bt), float(mode["cr_p90_bright"]), float(mode["cr_mean_bright"]),
float(mode["cr_p90_dark"]), float(mode["cr_mean_dark"])],
float(mode["cr_mean_dark"]) < float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK))
else:
var cr_p90 := float(mode["cr_p90_bright"]) if not dark else float(mode["cr_p90_dark"])
var cr_mean := float(mode["cr_mean_bright"]) if not dark else float(mode["cr_mean_dark"])
_check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name,
Kit.check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name,
float(BoardViewScript.DIGIT_INK_CR_P90), cr_p90],
cr_p90 >= float(BoardViewScript.DIGIT_INK_CR_P90))
var mean_gate := float(BoardViewScript.DIGIT_INK_CR_MEAN_BRIGHT) if not dark else float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK)
_check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean],
Kit.check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean],
cr_mean >= mean_gate)
if expect_plate.has(bt):
_check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt),
Kit.check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt),
"色牌" if bool(expect_plate[bt]) else "直画", mode_name], plate == bool(expect_plate[bt]))
@@ -7,38 +7,22 @@
extends SceneTree
const UiKitScript := preload("res://presentation/views/ui_kit.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var path: String = UiKitScript.FONT_UI
var f: Font = UiKitScript.load_font(path)
_check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null)
Kit.check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null)
if f != null:
_check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0)))
_check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
var sz := 18
var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
var w_label: float = f.get_string_size("攻击 2", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
_check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0)
Kit.check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0)
# 注:不设「私用区 has_char=false」负探针——fusion pixel 实测覆盖 PUA 码位,负向覆盖非契约面(宁缺勿错)
else:
_check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false)
if _failures > 0:
push_error("[ui_font_asset_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[ui_font_asset_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
Kit.check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false)
quit(Kit.finish("ui_font_asset_test"))
+72
View File
@@ -0,0 +1,72 @@
# tests/unit/test_kit.gd — 单元测试协议样板单点(T6 样板收口批 2026-10-02)
# 收口范围:11 份套件的 _check/_problems 样板(计数器 + PASS/FAIL 行 + 汇总退出)+
# reward_dangling_aux/aux_target_violation 两致命类用例各自复制的 BAD 表目录夹具。
# 用法(--script 模式无 autoload/全局标识符——禁 class_name 裸引用,使用方 preload):
# const Kit := preload("res://tests/unit/test_kit.gd")
# Kit.check("断言描述", cond) # 计数 + 即时 PASS/FAIL 行
# Kit.check_quiet(cond, "描述") # 计数 + 失败收集(finish 时统一汇总——原 _problems 风格,原参序)
# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败)
# 计数器为 static(套件 = 独立 Godot 子进程恰一份生命周期,见 run_all.gd 执行形态);
# 单进程跑多套件须先 Kit.reset()。
extends RefCounted
static var checks: int = 0
static var failures: int = 0
static var problems: Array = []
# 主通道:计数 + 即时 PASS/FAIL 行(config_db/rng_service/damage_math/system_contracts/
# damage_controller/ui_font_asset/terrain_tile_sets 等套件原 _check 同款输出,描述在前)
static func check(desc: String, ok: bool) -> void:
checks += 1
if ok:
print(" [PASS] " + desc)
else:
failures += 1
push_error(" [FAIL] " + desc)
# 静默收集通道:失败只记入 problems(即时零刷屏),finish 统一汇总。
# 参序沿原函数式套件 aux_items/breakpoint_resume 的 check(cond, msg)——条件在前,与主通道相反。
static func check_quiet(ok: bool, desc: String) -> void:
checks += 1
if not ok:
failures += 1
problems.append(desc)
# 汇总退出:返回退出码(调用方 `quit(Kit.finish(...))`)。pass_line = 成功尾行覆盖
#(保留原套件 PASS 摘要文案;缺省 = "all green (N checks)" 计数口径)。
# 失败路径:push_error 计数汇总 + problems 逐条 FAIL 行(print + push_error 双通道——原两风格并档)。
static func finish(suite: String, pass_line: String = "") -> int:
if failures > 0:
push_error("[%s] %d/%d 项失败" % [suite, failures, checks])
for p in problems:
print("[%s] FAIL — %s" % [suite, p])
push_error("[%s] FAIL — %s" % [suite, p])
return 1
print("[%s] %s" % [suite, pass_line if pass_line != "" else "all green (%d checks)" % checks])
return 0
# BAD 表夹具共享(原 reward_dangling_aux/aux_target_violation 各自复制的 _copy_tables):
# 整目录拷贝 src_dir → bad_dir(目录已建)。BAD_DIR 常量仍由各用例自持(名字互异——独立进程并行不互踩)。
static func copy_tables(src_dir: String, bad_dir: String) -> bool:
DirAccess.make_dir_recursive_absolute(bad_dir)
var dir := DirAccess.open(src_dir)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
if dir.copy(src_dir + "/" + f, bad_dir + "/" + f) != OK:
return false
f = dir.get_next()
return true
static func reset() -> void:
checks = 0
failures = 0
problems = []
+1
View File
@@ -0,0 +1 @@
uid://dtestkit123456