fix(iter37①): T2 呈现/组合根缺陷修——蓄力档分档·庆祝句柄·互斥四口·模态 fail-fast·站桩按 id 解析

- d2 蓄力档 `_intent_charge` 布尔分档(颜色相等判据退役,深浅金同值不再误判);d4 庆祝浮动 tween 句柄 kill 纪律(close 必杀)
- d5/d6/d7/d8 互斥四口:调试钮判据并入 `_sync_sim_button` 单点、组合根两守卫补模拟态/冒险态、`is_running()` 运行态查询面
- d9 ModalStack open/close fail-fast+遮挡账合并非覆盖(重复 open 不再吞旧账);d12 target_id 产发(damage_controller 四处)+站桩两口 id 先名兜底;d17 await 后实例守卫;d18 参数类型注解
- 断言:standee_fx_sweep_test(新增,18 项)/modal_flow_test V6-5(5 项 wd9,V6-1~4 原样同绿)/damage_controller_test target_id/pacing_queue_test 排播 target_id/system_test wd6+wd7
- 代拍板:d18 卡片字面 `lumas_out: Array[Variant] = null` 在 Godot 4.7 解析报错(typed array 不可默认 null),改用 `Variant = null`——唯一合法注法,零行为变化,随代拍板追认单留痕
This commit is contained in:
DKChan committed 2026-10-25 03:49:05 +08:00
1 parent 1b9736f065
commit 0529e1691c
19 files changed
+348 -21

No files matched your search

+4 -3
View File
@@ -166,6 +166,7 @@ func open_ticket(kind: String, source_class: String, display: String, channel: S
var matchup_o: String = (attack_type + "_vs_" + armor_b) if attack_type != "" else ""
var ac_payload := {"atk": int(base_value),
"target": String(tgt.get("display_name", "")) if tgt != null else "",
"target_id": String(tgt.get("enemy_id", "")) if tgt != null else "",
"matchup": matchup_o, "dig_streak": dig_streak, "nest_count": _nest_count()}
t.context["fx_attack_hits"] = _fx_hits("attack_commit", ac_payload)
t.context["fx_attack_payload"] = ac_payload
@@ -367,7 +368,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
var roll_read: Dictionary[Variant, Variant] = ctx["roll_read"]
var atk_type: String = ctx["atk_type"]
var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.settlement.crit_multiplier), int(ctx["cdelta"]))
var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name)}
var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))}
payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型)
payload["roll"] = int(roll_read.roll)
payload["ac"] = int(ctx["threshold"])
@@ -397,7 +398,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
# kill 发射+施加(敌人死亡确认;贯穿射击溢出弹射订阅面:对下一存活敌
# 以穿刺类型弹射本体 value%〔无次敌=弹射落空〕。B 模式入队段尾 flush 兑现;A 模式递归 resolve)
if bool(payload.get("killed", false)):
for h in _fx_hits("kill", {"target": String(target.display_name), "overflow": landed}):
for h in _fx_hits("kill", {"target": String(target.display_name), "target_id": String(target.get("enemy_id", "")), "overflow": landed}):
if String(h.get("kind", "")) == "reveal_tiles" and fx_reveal_request.is_valid():
fx_reveal_request.call() # 斩首学者——击杀揭示 1 格(编排器入队,授予段末兑付)
continue
@@ -501,7 +502,7 @@ func _resolve_board_typed(t: Dictionary) -> void:
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.settlement.damage_floor))
units.apply_damage(target, dealt)
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt,
{"target": String(target.display_name), "attack_type": String(t.attack_type), "effectiveness": cdelta}, String(t.highlight))
{"target": String(target.display_name), "target_id": String(target.get("enemy_id", "")), "attack_type": String(t.attack_type), "effectiveness": cdelta}, String(t.highlight))
_outcomes[int(t.ticket_id)] = "redeemed"
+5 -5
View File
@@ -708,9 +708,9 @@ func _on_battle_end_continue() -> void:
func _start_debug_levels() -> void:
# 守卫:调试对局与活跃冒险/结算并存不开放——并发会清写
# settlement/编排态=状态机破坏;对局中开窗是只读浮窗,不是开局前置
if run_scope.is_adventure or run_scope.settlement != null:
# 守卫:调试对局与活跃冒险/结算/批量模拟并存不开放——并发会清写
# settlement/编排态=状态机破坏;模拟在跑时 reset_sliders 会作废测量读数(d5 置灰之外的信号绕过闸)
if run_scope.is_adventure or run_scope.settlement != null or _tune_sim.is_running():
return
debug_overlay.reset_sliders()
_close_modal(debug_overlay) # 浮窗内入口发起——随开随收(对局期不可再开:门禁在 _show_debug_overlay)
@@ -1483,8 +1483,8 @@ func _on_tune_apply(atk_pct: int, hp_pct: int) -> void:
func _on_tune_simulate(n: int) -> void:
# 批量模拟编排 = 控制器承接(tune_sim_controller.gd);E4 后无表态需复位——模拟走 runner 覆盖参数。
# 对局中禁用兜底(长程异步模拟与活跃结算并存态不开放;探针 emit 绕过 disabled 时由此守住)
if run_scope.settlement != null:
# 对局中禁用兜底(长程异步模拟与活跃冒险/结算并存态不开放;探针 emit 绕过 disabled 时由此守住)
if run_scope.settlement != null or run_scope.is_adventure:
return
_tune_sim.run(n)
+14 -2
View File
@@ -19,18 +19,30 @@ func register(m: Control, tags: Array = []) -> void:
func open(m: Control) -> void:
var hidden: Array[Control] = []
var idx := _list.find(m)
if idx < 0:
push_error("ModalStack.open: 未登记层不可打开(register 先行)")
return
var hidden: Array[Control] = []
for i in range(idx + 1, _list.size()):
var higher: Control = _list[i]
if is_instance_valid(higher) and higher.visible:
higher.visible = false
hidden.append(higher)
_occluded[m] = hidden
# 遮挡账合并非覆盖:同层重复 open 时上方已隐、hidden 为空,旧账须保留
# (空表覆盖会让先前被隐层在本层 close 后永不恢复)
var merged: Array = _occluded.get(m, []).duplicate()
for h in hidden:
if not merged.has(h):
merged.append(h)
_occluded[m] = merged
m.visible = true
func close(m: Control) -> void:
if _list.find(m) < 0:
push_error("ModalStack.close: 未登记层不可关闭(register 先行)")
return
m.visible = false
for h in _occluded.get(m, []):
if is_instance_valid(h):
+4 -2
View File
@@ -108,11 +108,12 @@ func _play_settlement_mark(row: Dictionary) -> void:
if mark_type != "effect" and mark_type != "damage" and mark_type != "heal":
return
var target := String(row.get("target", ""))
var target_id := String(row.get("target_id", ""))
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
var v = stage.enemy_standee_center(target_id, 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":
@@ -133,6 +134,7 @@ func _play_cross_zone_fx(row: Dictionary) -> void:
match String(row.get("type", "")):
"damage":
var target := String(row.get("target", ""))
var target_id := String(row.get("target_id", ""))
var crit := bool(row.get("crit", false))
if target == "" or target == "hero":
# 主角受击弹道(禁「敌→主角零反馈」早退):⚔ 危险红飞向主角站桩;
@@ -140,7 +142,7 @@ func _play_cross_zone_fx(row: Dictionary) -> void:
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "⚔",
UiKit.RED_MINE, crit)
return
var to_v = stage.enemy_standee_center(target)
var to_v = stage.enemy_standee_center(target_id, target)
if to_v != null:
settlement_fx.play_projectile(from_v, to_v, "⚔",
UiKit.GOLD if crit else UiKit.RED_MINE, crit)
+8
View File
@@ -11,6 +11,7 @@ var db
var panel # 调试浮窗 setter 面:atk_pct/hp_pct/set_sim_running/set_sim_progress/set_sim_report
var _base_hp := 0 # 报告中的"基础 HP"口径注记值
var _driver = null # 关卡自动策略驱动(调用方注入;null = 旧行为运行时 load 兜底)
var _running := false # 运行态(run 全程 true——组合根互查守卫用,与面板置灰解耦)
func _init(db_ref, panel_ref, base_hp: int, driver = null) -> void:
@@ -27,7 +28,13 @@ func _autoplay():
return load("res://sim/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3 兜底路径)
# 运行态查询出口(组合根互查守卫:模拟在跑期禁开调试对局/二次模拟)
func is_running() -> bool:
return _running
func run(n: int) -> void:
_running = true
# 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/通关全量 JSONL;
# 一文件多局,run 行携带种子切分局次)
var logger = PlaytestLogger.new()
@@ -60,3 +67,4 @@ func run(n: int) -> void:
panel.set_sim_report(HudPresenter.tune_sim_report(n, panel.atk_pct(), panel.hp_pct(),
wins, dmg_all, hp_left_win, hp_boss, rounds_all, death_nodes, _base_hp))
panel.set_sim_running(false)
_running = false
@@ -24,6 +24,7 @@ var _bonus_row_l: HBoxContainer
var _bonus_name: Label
var _bonus_val: Label
var _cele: TextureRect # 庆祝立绘(idle 占位浮动)
var _celebrate_tw: Tween # 庆祝浮动循环句柄(每调先 kill,close 必杀——无限循环禁裸创建)
var _btn: Button
var _board_rect := Rect2()
@@ -158,6 +159,9 @@ func close() -> void:
_seal.visible = false
_seal.modulate = Color.WHITE
_cele.visible = false
if _celebrate_tw != null and _celebrate_tw.is_valid():
_celebrate_tw.kill()
_celebrate_tw = null
_clear_crest_bricks()
@@ -281,7 +285,10 @@ func _play_dust_sadle() -> void:
func _play_celebrate() -> void:
if UiKit.motion_scale < 1.0:
return
var tw := _cele.create_tween()
if _celebrate_tw != null and _celebrate_tw.is_valid():
_celebrate_tw.kill()
_celebrate_tw = _cele.create_tween()
var tw := _celebrate_tw
tw.set_loops()
tw.tween_property(_cele, "position:y", _cele.position.y - 4.0, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(_cele, "position:y", _cele.position.y, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+20 -3
View File
@@ -305,7 +305,9 @@ func on_step_row_fx(row: Dictionary) -> void:
_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)
var card: Node2D = _card_by_id(String(row.get("target_id", "")))
if card == null:
card = _card_by_name(target) # 旧行无 target_id 时名兜底
if card == null:
return
_schedule_arrival_fx(card, bool(row.get("killed", false)))
@@ -338,9 +340,24 @@ func _card_by_name(display_name: String) -> Node2D:
return null
# 按敌单位 id 精确解析站桩(同名敌主键——空 id 恒不命中,由调用点名兜底)
func _card_by_id(enemy_id: String) -> Node2D:
if enemy_id == "":
return null
for c in _cards:
if not is_instance_valid(c):
continue
if c.enemy_id() == enemy_id:
return c
return null
# 跨区弹道目标坐标(结算演出链批——组合根传给 SettlementFxView 的全局锚点;壳层口径 C4)
func enemy_standee_center(display_name: String):
var card := _card_by_name(display_name)
# 解析 id 先、display_name 兜底(旧行无 target_id)
func enemy_standee_center(target_id: String, display_name: String) -> Variant:
var card := _card_by_id(target_id)
if card == null:
card = _card_by_name(display_name)
if card == null or not is_instance_valid(card):
return null
return world_to_shell(card.position + card.box * 0.5)
+1 -1
View File
@@ -995,7 +995,7 @@ static func _flip_tex(tex: Texture2D) -> Texture2D:
# 地板瓦片明度归一(方向稿 _v3_darken_floors 同式移植):8×8 双线性重采样均值测光
# (sRGB→线性域,Rec.709 系数),增益 k=(目标/实测)^(1/2.2) 钳 [0.6,1.8],逐通道 sRGB 域线性拉伸。
# 输入恒为 freshly-loaded 原图(load_tex 每次盘读)——幂等可重入;32×32 五变体开销可忽略
static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out = null) -> Texture2D:
static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out: Variant = null) -> Texture2D:
var img: Image = tex.get_image()
if img.is_compressed():
img.decompress()
+3 -1
View File
@@ -81,7 +81,6 @@ func set_sim_running(running: bool) -> void:
# 在活跃对局并存态不开放(长程异步模拟/并发对局编排与活跃结算并存态破坏状态机)
func set_context_locked(locked: bool) -> void:
_ctx_locked = locked
_dbg_btn.disabled = _ctx_locked
_sync_sim_button()
@@ -97,9 +96,12 @@ func run_data_probe() -> Dictionary:
return _run_data.duplicate()
# 两钮同判据(模拟运行 / 对局上下文锁定对「模拟」「两层调试」两入口等价)——
# 判据单点:两 setter 都经此同步,禁在调用点各写一份
func _sync_sim_button() -> void:
_sim_btn.disabled = _sim_running or _ctx_locked
_sim_btn.text = "模拟中…" if _sim_running else "运行模拟"
_dbg_btn.disabled = _sim_running or _ctx_locked
func set_sim_progress(text: String) -> void:
@@ -92,6 +92,8 @@ func play_damage_number(pos: Vector2, text: String, col: Color, crit: bool = fal
lab.z_index = 6
add_child(lab)
await get_tree().process_frame # 等一帧取实际尺寸(文本度量)
if not is_instance_valid(lab):
return # 等待帧间节点已释放(父层收起/清场)——守卫后立即退出,禁触已释放实例
lab.position = pos - lab.size * 0.5
var tw := lab.create_tween()
tw.tween_property(lab, "position:y", lab.position.y - 40.0, UiKit.motion(1.1)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+11 -2
View File
@@ -31,6 +31,7 @@ var _state_revert_tw: Tween
var _order := 1
var _intent_glyph := ""
var _intent_col := Color.WHITE
var _intent_charge := false # 蓄力档(pid ∈ CHARGE_SKILL_IDS——意图三档 ③ 分支判据)
var _intent_visible := false
var _intent_scale := 1.0: # 意图弹入动画参数
set(v):
@@ -66,6 +67,11 @@ func display_name() -> String:
return _display_name
# 敌单位身份出口(d12 站桩解析主键——同名敌按 id 精确匹配;主角站桩恒空串)
func enemy_id() -> String:
return _enemy_id
# 血条存在性探针出口(世界直角血条常在)
var has_world_hp_bar := true
@@ -153,10 +159,12 @@ func set_order(i: int) -> void:
# "" = 未摇定/死亡残位)。不显示技能名——类型级图标 + 一次性动作动画。
func set_pending(ptype: String, pid: String) -> void:
if _dead:
_intent_charge = false
_intent_visible = false
queue_redraw()
return
if ptype == "" or ptype == "basic":
_intent_charge = false
_intent_visible = false # 普通攻击零表示(完全不可预测——§2.4)
queue_redraw()
return
@@ -168,7 +176,8 @@ func set_pending(ptype: String, pid: String) -> void:
elif ptype == "debuff":
glyph = "☠" # 妨害系
col = UiKit.DEBUFF_PURPLE
if pid in CHARGE_SKILL_IDS:
_intent_charge = pid in CHARGE_SKILL_IDS
if _intent_charge:
col = UiKit.GOLD_INK # 重击类蓄力档(深金墨——浅垫牌上 ≥4.5:1)
_intent_glyph = glyph
_intent_col = col
@@ -185,7 +194,7 @@ func _play_intent_motion() -> void:
tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.16))
tw.parallel().tween_property(self, "_glyph_rot", 4.0, UiKit.motion(0.10))
tw.tween_property(self, "_glyph_rot", 0.0, UiKit.motion(0.14))
if _intent_col == UiKit.GOLD_DEEP:
if _intent_charge:
tw.tween_property(self, "_intent_scale", 1.22, UiKit.motion(0.28)).set_trans(Tween.TRANS_SINE) # ③蓄力缓推
tw.tween_interval(UiKit.motion(0.40)) # 定格微顿
tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.18))
+1 -1
View File
@@ -25,7 +25,7 @@ const PANEL_BORDER := Color8(120, 110, 88) # 面板细线框;兼熄灭点
const GOLD := Color8(240, 168, 40) # 奖励金
const GOLD_DEEP := Color8(176, 122, 20) # 深金(金框描边/达意金)
const GOLD_INK := Color8(116, 80, 11) # 深金墨(意图蓄力徽标墨——PANEL_DATA 垫牌上 4.9:1)
const GOLD_BRIGHT := Color8(224, 184, 96) # 金族亮档(敌顺位徽标①②③/重甲徽标对暗舞台底 6.5:1;值=收益金 e0b860 同门;GOLD_DEEP 语义岗〔standee:199 意图判据/board_view:35/level_map:237〕在役不动)
const GOLD_BRIGHT := Color8(224, 184, 96) # 金族亮档(敌顺位徽标①②③/重甲徽标对暗舞台底 6.5:1;值=收益金 e0b860 同门)
const GOLD_LIT := Color8(230, 190, 90) # 亮金(候选高亮)
const GOLD_DIM := Color8(140, 116, 60) # 暗金(已过节点)
const GOLD_DONE := Color8(104, 86, 44) # 已过节点暗金·关卡画面呈现态 #68562C(GOLD_DIM #8C743C 对双色墨均 <4.5:1,对比硬指标逼守此值;GOLD_DIM 他用不动)
+1
View File
@@ -59,6 +59,7 @@ func _suites() -> Array:
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
{"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值)
{"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # AC⑤/T9——排空零分叉/零丢行/延迟双态
{"path": "res://tests/unit/presentation/standee_fx_sweep_test.gd", "expect": 0}, # 呈现域缺陷扫掠(d2 蓄力档/d4 庆祝句柄/d5+d8 互斥/d12 站桩按 id 解析)
{"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 奖励节点 UI 直验(盲区补漏)
{"path": "res://tests/unit/presentation/level_map_info_fog_test.gd", "expect": 0}, # 信息迷雾——呈现显隐/Boss 豁免/详情零显示/大图拟合
{"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 工作间两区六路线 UI 直验(V13 自动化前置面)
+42
View File
@@ -40,6 +40,7 @@ func _split_tail() -> void:
await _smoke_scene_split() # 协程函数必须 await(无 await=发射后不管,首个 await 后即被 quit 截断)
await _smoke_boot_chain()
await _smoke_run_lifecycle()
await _smoke_sim_guards()
await _smoke_lifecycle_matrix()
else:
check(false, "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)")
@@ -269,6 +270,47 @@ func _smoke_run_lifecycle() -> void:
DirAccess.remove_absolute(p)
# ---- d6/d7 批量模拟互斥守卫(wd6/wd7——白盒置态直调入口,以守卫早退的可见后果为断言面)----
func _smoke_sim_guards() -> void:
print("--- 卡① T2:批量模拟互斥两守卫(d6/d7)---")
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
var sm = main.get_node("/root/SaveManager")
for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_sim_guards.json")
sm.mark_first_adventure_done()
# wd6:闲时态 + 模拟运行态 → 调试两层入口守卫早退(开始层不收起、无结算对象)
check(main.run_scope.settlement == null and not main.run_scope.is_adventure,
"前置:闲时会话(settlement 空 + is_adventure=false)")
main._start_overlay.visible = true
main._tune_sim._running = true
main._start_debug_levels()
check(main.run_scope.settlement == null and main._start_overlay.visible,
"模拟运行态下调试两层入口守卫早退(开始层不收起)〔wd6〕")
main._tune_sim._running = false
# wd7:真实冒险关间态(is_adventure=true、settlement==null)→ 模拟入口守卫早退
main.debug_overlay.seed_edit.text = "42"
main._start_adventure_from_menu()
await process_frame
check(main.run_scope.is_adventure and main.run_scope.settlement == null,
"前置:真实冒险关间态(选路停点,非战斗)")
main._on_tune_simulate(3)
check(not main._tune_sim.is_running(),
"冒险关间态下批量模拟入口守卫早退(d7 补 is_adventure)〔wd7〕")
main._tune_sim._running = false # 红态清理(守卫缺失时 run 已启动)
main.run_scope.adventure.abandon()
for i in range(8):
await process_frame
main.queue_free()
await process_frame
for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采)
# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1)
func _face_snapshot(main: Node) -> Dictionary:
@@ -30,6 +30,7 @@ func _init() -> void:
_test_policy_immediate_mode(db)
_test_source_death_void(db)
_test_identical_content_dual_enemy(db)
_test_target_id_payload(db)
_test_requires_gate_and_zero(db)
_test_hero_death_halt(db)
_test_ac_merge(db)
@@ -254,6 +255,28 @@ func _test_identical_content_dual_enemy(db) -> void:
# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ----
# d12 产发端增键:目标身份随行/随载荷下发(同名敌按 id 精确解析的产发半)
func _test_target_id_payload(db) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
Kit.check("前置:units.spawn(1,0) 成功(target_id 断言)", false)
return
var target = h.units.default_target()
var expect_id := String(target.get("enemy_id", ""))
Kit.check("前置:目标敌携非空 enemy_id", expect_id != "")
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack",
"T6目标id", "战斗段", int(db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1)
var fx_payload: Dictionary = ticket.context.get("fx_attack_payload", {})
Kit.check("attack_commit fx 载荷携 target_id〔wd12〕",
String(fx_payload.get("target_id", "")) == expect_id and String(fx_payload.get("target", "")) != "")
h.ctrl.submit(ticket)
h.ctrl.flush()
var row := _row_by_display_prefix("T6目标id")
Kit.check("伤害行携 target_id == 目标敌 enemy_id〔wd12〕",
not row.is_empty() and String(row.extra.get("target_id", "")) == expect_id)
func _test_requires_gate_and_zero(db) -> void:
_rows = []
var h = _harness(db)
@@ -87,6 +87,7 @@ func _init() -> void:
# V6-4 演播后排窗挂起+边沿派发(T17 语义)
var end_ov := Control.new()
end_ov.visible = false
flow.register(end_ov) # d9 fail-fast 后:未登记层不可进入编排(夹具按 register 先行契约登记)
rows["empty"] = false
var sync_before1: int = calls["open_sync"]
flow.open_after_playback(end_ov)
@@ -105,9 +106,34 @@ func _init() -> void:
flow.open_after_playback(end_ov)
rows["empty"] = true
var other := Control.new()
flow.register(other)
var sync_before: int = calls["open_sync"]
flow.open(other)
Kit.check_quiet(end_ov.visible and flow._deferred == null and calls["open_sync"] == sync_before + 2,
"V6 挂起兜底:开新层先结挂起(T17 防永不兑现——挂起层先开+新层后开两拍)〔迭代27 T4〕")
# V6-5 遮挡失败快+遮挡账合而非覆盖(d9)
var ms = ModalStackScript.new()
var ma := Control.new()
var mb := Control.new()
var stranger := Control.new()
stranger.visible = false # Control 默认 visible=true——先隐,才能断言 fail-fast 未把它照上屏
ms.register(ma)
ms.register(mb)
ms.open(ma)
ms.open(stranger) # 未登记层:push_error + 早退(零副作用)
Kit.check_quiet(not stranger.visible and ma.visible,
"V6 未登记层 open fail-fast(该层不可见、已开层不变)〔wd9〕")
ms.close(stranger)
Kit.check_quiet(not stranger.visible and ma.visible,
"V6 未登记层 close fail-fast(已开层不受影响)〔wd9〕")
ms.open(mb)
ms.open(ma) # 开低层隐其上方可见层
Kit.check_quiet(not mb.visible, "V6 开低层隐其上方层(重复 open 前态)〔wd9〕")
ms.open(ma) # 同层重复 open:上方已隐,hidden 空表——旧账须保留
Kit.check_quiet(not mb.visible, "V6 重复 open 保持遮挡(V6 在役语义不变)〔wd9〕")
ms.close(ma)
Kit.check_quiet(mb.visible, "V6 重复 open 不吞遮挡账(close 低层恢复 mb——合并非覆盖)〔wd9〕")
ms.close(mb)
quit(Kit.finish("modal_flow"))
@@ -7,9 +7,35 @@
# 运行:godot --headless --path game --script res://tests/unit/presentation/pacing_queue_test.gd(expect 0)
extends SceneTree
const RowPacerScript := preload("res://presentation/row_pacer.gd")
var _problems: Array = []
# 记参假站桩(d12 排播半):继承真类以过类型面,只记解析入参
class FakeStage extends BattleStageView:
var calls: Array = []
func enemy_standee_center(target_id: String, display_name: String) -> Variant:
calls.append([target_id, display_name])
return Vector2(10, 10)
# 记参假演出层(同上——play_* 全部只记不演)
class FakeFx extends SettlementFxView:
var calls: Array = []
func play_projectile(_from: Vector2, _to: Vector2, _glyph: String, _col: Color, _crit: bool = false) -> void:
calls.append(["projectile", _to])
func play_damage_number(_pos: Vector2, _text: String, _col: Color, _crit: bool = false) -> void:
calls.append(["mark", _pos])
func play_status_pulse(_pos: Vector2, _col: Color) -> void:
calls.append(["pulse", _pos])
func check(cond: bool, msg: String) -> void:
if not cond:
_problems.append(msg)
@@ -98,6 +124,21 @@ func _init() -> void:
UiKit.motion_scale = 0.5
check(main.pace_delay_for("damage", "heal") == 0.0, "减动效段间延迟归零(行不跳过——同帧直落快路)")
UiKit.motion_scale = 1.0
# ---- 断言④:排播两处取用按行 target_id 解析站桩(d12 排播半——假 stage 记参)----
var rp = RowPacerScript.new()
var fstage := FakeStage.new(null)
var ffx := FakeFx.new()
rp.stage = fstage
rp.board_view = main.board_view
rp.settlement_fx = ffx
rp._play_settlement_mark({"type": "damage", "target": "哥布林", "target_id": "goblin_b", "value": 3})
check(fstage.calls.size() == 1 and fstage.calls[0] == ["goblin_b", "哥布林"],
"结算标出按行 target_id 解析站桩(id 与名同传——d12)")
rp._play_cross_zone_fx({"type": "damage", "target": "哥布林", "target_id": "goblin_b", "value": 3})
check(fstage.calls.size() == 2 and fstage.calls[1] == ["goblin_b", "哥布林"],
"跨区弹道按行 target_id 解析站桩(id 与名同传——d12)")
check(ffx.calls.size() == 2, "两路解析成功后演出层各接一拍(记参面非空)")
rp.free()
_finish()
@@ -0,0 +1,133 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 呈现域缺陷扫掠断言(行为缺陷定点修批卡① T2 五族):
# ① 意图蓄力档可达(d2——_intent_charge 分档,旧 GOLD_DEEP 判据恒不可达);
# ② 庆祝 tween 句柄纪律(d4——重开不叠加、close 必杀);
# ③ 互斥四口(d5 调试钮同判据 / d8 控制器运行态可查);
# ⑤ 站桩按敌 id 解析(d12 两口:on_step_row_fx 闪白落点 + enemy_standee_center 弹道落点)。
# d6/d7 两守卫断言宿主 = system_test;d3/d17/d18 属非行为面(实现报告等价申报)。
# 运行:godot --headless --path game --script res://tests/unit/presentation/standee_fx_sweep_test.gd(expect 0)
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const StandeeBodyScript := preload("res://presentation/views/standee_body.gd")
const BattleEndScript := preload("res://presentation/views/battle_end_overlay.gd")
const DebugOverlayScript := preload("res://presentation/views/debug_overlay.gd")
const TuneSimScript := preload("res://presentation/tune_sim_controller.gd")
const StageScript := preload("res://presentation/views/battle_stage_view.gd")
# 假调试面板(TuneSimController 所需 setter 面 + 树内节点供给 get_tree)
class FakePanel extends Node:
var running := false
var progress := ""
var report := ""
var atk := 0
var hp := 0
func atk_pct() -> int:
return atk
func hp_pct() -> int:
return hp
func set_sim_running(r: bool) -> void:
running = r
func set_sim_progress(t: String) -> void:
progress = t
func set_sim_report(t: String) -> void:
report = t
# 假自动策略(run_level 签名同 level_autoplay——回调内读控制器运行态)
class FakeDriver extends RefCounted:
var ctrl
var seen: Array = []
var calls := 0
func run_level(_db, _seed: int, _level: int, _atk: int, _hp: int, _other: bool, _logger):
calls += 1
seen.append(ctrl.is_running())
if calls == 1:
return {"hero_dmg": 12.0, "rounds": 4, "result": "victory", "hp_left": 3.0, "hp_at_boss": 5.0}
return {"hero_dmg": 6.0, "rounds": 2, "result": "defeat",
"defeat_node_index": 3, "defeat_node_type": "battle", "hp_left": 0.0, "hp_at_boss": 0.0}
func _init() -> void:
UiKit.motion_scale = 1.0
# ---- ⑤ 站桩按 id 解析(两口共用同名字敌载体)----
var stage = StageScript.new(null)
root.add_child(stage)
var s1 = StandeeBodyScript.new("enemy")
var s2 = StandeeBodyScript.new("enemy")
stage.world.add_child(s1)
stage.world.add_child(s2)
s1.set_data({"display_name": "哥布林", "enemy_id": "goblin_a", "hp": 3, "max_hp": 3, "order": 1})
s2.set_data({"display_name": "哥布林", "enemy_id": "goblin_b", "hp": 3, "max_hp": 3, "order": 2})
s1.position = Vector2(10, 20)
s2.position = Vector2(200, 20)
stage._cards.clear()
stage._cards.append(s1)
stage._cards.append(s2)
Kit.check("站桩 enemy_id 访问器读装载值〔wd12〕", s1.enemy_id() == "goblin_a" and s2.enemy_id() == "goblin_b")
Kit.check("同名敌按 id 精确解析(不做名子串撞车)〔wd12〕",
stage._card_by_id("goblin_b") == s2 and stage._card_by_id("goblin_a") == s1)
Kit.check("空 id / 未登记 id 恒不命中(名兜底归调用点)〔wd12〕",
stage._card_by_id("") == null and stage._card_by_id("no_such") == null)
# 口一:on_step_row_fx —— 伤害行携 target_id,闪白落点须在 id 指向的站桩
stage.on_step_row_fx({"type": "damage", "target": "哥布林", "target_id": "goblin_b"})
await create_timer(0.6).timeout # 弹道到达拍 0.33s + 余量
Kit.check("伤害行按 target_id 命中正确站桩(闪白落点)〔wd12〕",
s2._flash_count == 1 and s1._flash_count == 0)
# 口二:enemy_standee_center —— id 先、display_name 兜底
var c_b = stage.enemy_standee_center("goblin_b", "哥布林")
var c_b_expect: Vector2 = stage.world_to_shell(s2.position + s2.box * 0.5)
Kit.check("弹道落点按 target_id 取站桩中心〔wd12〕", c_b != null and c_b == c_b_expect)
var c_fallback = stage.enemy_standee_center("", "哥布林")
var c_fallback_expect: Vector2 = stage.world_to_shell(s1.position + s1.box * 0.5)
Kit.check("无 target_id 时按名兜底同旧行为(首命中)〔wd12〕", c_fallback != null and c_fallback == c_fallback_expect)
# ---- ① 意图蓄力档可达(d2)----
s1.set_pending("damage", "heavy")
Kit.check("蓄力技能(heavy)置 _intent_charge=true〔wd2〕", s1._intent_charge)
s1.set_pending("damage", "goblin_slash")
Kit.check("非蓄力攻击技(goblin_slash)零置位〔wd2〕", not s1._intent_charge)
s1.set_pending("buff", "goblin_rage")
Kit.check("增益系零置位〔wd2〕", not s1._intent_charge)
s1.set_pending("damage", "basic")
Kit.check("普攻零表示路径复位 _intent_charge〔wd2〕", not s1._intent_charge)
# ---- ② 庆祝 tween 句柄纪律(d4)----
var ov = BattleEndScript.new()
root.add_child(ov)
ov.open_victory("判定行", "继续", Rect2(0, 0, 100, 100))
var first: Tween = ov._celebrate_tw
Kit.check("首开胜利画面创建庆祝句柄〔wd4〕", first != null and first.is_valid())
ov.open_victory("判定行", "继续", Rect2(0, 0, 100, 100))
Kit.check("重开胜利画面:旧句柄已 kill、新句柄在位(至多一个活跃)〔wd4〕",
first != null and not first.is_valid() and ov._celebrate_tw != null and ov._celebrate_tw.is_valid())
ov.close()
Kit.check("close 必杀庆祝句柄(句柄清空)〔wd4〕", ov._celebrate_tw == null)
# ---- ③ 互斥四口(d5/d8)----
var dbg = DebugOverlayScript.new(null, 0, 0)
dbg.set_sim_running(true)
Kit.check("模拟运行期调试入口钮同置灰〔wd5〕", dbg._dbg_btn.disabled)
dbg.set_sim_running(false)
Kit.check("模拟收尾调试入口钮恢复(未锁定)〔wd5〕", not dbg._dbg_btn.disabled)
dbg.free()
var panel := FakePanel.new()
root.add_child(panel)
var drv := FakeDriver.new()
var ctrl = TuneSimScript.new({"action_economy": {"ap_per_round": 2}}, panel, 100, drv)
drv.ctrl = ctrl
await ctrl.run(2) # 假 driver 回调内读 is_running()(两局各一读)
Kit.check("控制器运行中 is_running()=true(驱动回调内读数)〔wd8〕", drv.seen == [true, true])
Kit.check("控制器收尾 is_running()=false〔wd8〕", not ctrl.is_running() and not panel.running)
Kit.check("两局报告全文注入面板(run 收尾链路完整)〔wd8〕", String(panel.report) != "")
quit(Kit.finish("standee_fx_sweep"))
@@ -0,0 +1 @@
uid://b3vqjio51r2nf