276 lines
13 KiB
GDScript
276 lines
13 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# 轮间结算节奏化断言(逻辑先行断言面):
|
||
# ① 演出队列排空前后核心逻辑状态逐位一致(演出关停不产生分叉——排播队列零核心触碰的结构证明);
|
||
# ② 零丢行:step_row 信号总数 = 明细带实收行数(排播/排空两路都不吞行);
|
||
# ③ 段间延迟取数面双态(常态 >0 / 减动效 =0 归零不跳过——pace_delay_for 直读)。
|
||
# 载体:main.tscn 实例(同 ui_click_probe 形态——组合根全链在座),真实冒险驱动至首战结算后断言。
|
||
# 运行: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[Array] = []
|
||
var fx_rows: Array[Dictionary] = [] # on_step_row_fx 收到的行(FX 链计数载体)
|
||
|
||
func on_step_row_fx(row: Dictionary) -> void:
|
||
fx_rows.append(row.duplicate(true))
|
||
|
||
func enemy_standee_center(target_id: String, display_name: String) -> Variant:
|
||
calls.append([target_id, display_name])
|
||
return Vector2(10, 10)
|
||
|
||
func hero_standee_center() -> Vector2:
|
||
return Vector2(10, 10)
|
||
|
||
|
||
# 记参假演出层(同上——play_* 全部只记不演)
|
||
class FakeFx extends SettlementFxView:
|
||
var calls: Array[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:
|
||
# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组)
|
||
if not cond:
|
||
_problems.append(msg)
|
||
|
||
|
||
func _init() -> void:
|
||
# 判据出处: design/gdd/systems/表现与支撑/HUD框架.md:272 (排播节奏——排空零分叉/零丢行/延迟双态组)
|
||
var main: Node = load("res://presentation/main.tscn").instantiate()
|
||
root.add_child(main)
|
||
await process_frame
|
||
# 隔离换注(SaveManager 是 autoload——原地 attach:先删后建防陈旧档泄入,
|
||
# 读盘在 attach 内完成;meta 经 save 引用透传零重建)
|
||
for p in ["user://test_pacing_queue_save.json", "user://test_pacing_queue_save.json.tmp"]:
|
||
if FileAccess.file_exists(p):
|
||
DirAccess.remove_absolute(p)
|
||
var sm = main.get_node("/root/SaveManager")
|
||
sm.slots.attach_path("user://test_pacing_queue_save.json")
|
||
sm.meta_state.mark_first_adventure_done()
|
||
# 行信号计数器(真实信号路径旁挂计数——零触碰既有接线)
|
||
var rows_seen: Array = []
|
||
main._start_adventure_from_menu()
|
||
await process_frame
|
||
# 入口停点选路进首战(真实停点链)
|
||
if not main.ui.modals.run.level_screen.visible:
|
||
_problems.append("入口停点未呈现(前置失守)")
|
||
_finish()
|
||
return
|
||
main.run_scope.runner.pick_next_node(int(main.run_scope.runner.next_candidate_ids()[0]))
|
||
await process_frame
|
||
# 等首战开打(board_changed 同步链——两帧余量)
|
||
await process_frame
|
||
var battle = main.run_scope.runner.current_battle()
|
||
if battle == null:
|
||
_problems.append("首战未开打(排播断言无载体)")
|
||
_finish()
|
||
return
|
||
battle.step_row.connect(_count_row.bind(rows_seen))
|
||
# 理性策略挖到触发结算(≥1 行入队即达断言前提——AP 一次消耗;挖格须邻英雄——邻接优先排序)
|
||
var ms = battle.minesweeper
|
||
# 理性策略驱动(auto_player 单一实现复用——damage_probe 同款两步消费:先 move 到 hero_cell 再 dig)
|
||
var ap = load("res://sim/auto_player.gd")
|
||
var acted := false
|
||
for guard in range(40):
|
||
var plan: Dictionary = ap.choose_dig(ms)
|
||
if plan.is_empty():
|
||
break
|
||
if plan.hero_cell != ms.hero_pos():
|
||
ms.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||
if battle.action_economy.phase != "Active":
|
||
break
|
||
var outcomes: Array = ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||
acted = not outcomes.is_empty() and bool(outcomes[0].accepted)
|
||
if acted:
|
||
break
|
||
# 轮末结算触发(step_row 行族只在轮末 emit——停手即结,damage_probe 同款)
|
||
if battle.action_economy.phase == "Active":
|
||
ms.settle_batch([{"action": "stop"}])
|
||
if not acted:
|
||
_problems.append("首战无受理动作(队列断言无行)")
|
||
_finish()
|
||
return
|
||
await process_frame
|
||
# ---- 断言①:排空前后核心状态逐位一致(演出关停零分叉)----
|
||
var snap_before: String = JSON.stringify(battle.diagnostic_snapshot())
|
||
var hp_before: int = int(battle.action_economy.ap) + int(battle.action_economy.rap) * 0 # 观测面双录
|
||
main.ui.screens.row_pacer.flush()
|
||
var snap_after: String = JSON.stringify(battle.diagnostic_snapshot())
|
||
check(snap_before == snap_after, "排空前后核心诊断快照逐位一致(AC⑤ 演出关停零分叉)")
|
||
# ---- 断言②:零丢行(逐行文字列表退役后改判〔退役改 V-退役卡 R2〕:结构判据=排空后队列恒空,
|
||
# 行的落地面由「原文在明细带」改为「逐行进 FX 链」——由下方 _assert_fx_chain 对全行集断言承接)----
|
||
check(rows_seen.size() > 0, "结算行已产生(断言前提)")
|
||
check(main.ui.screens.row_pacer.pending_rows().is_empty(), "排空后队列恒空(flush 完整性)")
|
||
# ---- 断言②改判承接:FX 链逐行收拍(真实行集驱动——每行一拍站桩反馈,弹道/标出按型计数逐位对上)----
|
||
_assert_fx_chain(main, rows_seen)
|
||
# ---- 断言③:段间延迟取数面双态 ----
|
||
UiKit.motion_scale = 1.0
|
||
var d_on: float = main.pace_delay_for("damage", "heal")
|
||
var d_same: float = main.pace_delay_for("damage", "damage")
|
||
check(d_on > 0.0 and d_on >= d_same, "常态段间延迟 >0 且 ≥ 段内(%f/%f)" % [d_on, d_same])
|
||
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, root.get_node("/root/ConfigDB").presentation.battle_layout)
|
||
var ffx := FakeFx.new()
|
||
rp.stage = fstage
|
||
rp.board_view = main.ui.battle.board_zone.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)〔wd12〕")
|
||
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)〔wd12〕")
|
||
check(ffx.calls.size() == 2, "两路解析成功后演出层各接一拍(记参面非空)〔wd12〕")
|
||
rp.free()
|
||
# ---- 断言⑤:会话更替即清队(wr4——前置夹具入队再走真实入口 _begin_session;
|
||
# 无夹具则改前改后皆绿:flush 无队列可清时两种形态同值)----
|
||
UiKit.motion_scale = 1.0 # 常态入队(减动效快路同帧直落不入队——夹具须真落队列)
|
||
main._on_step_row({"type": "damage", "target": "hero", "value": 1})
|
||
main._on_step_row({"type": "heal", "target": "hero", "value": 2})
|
||
check(main.ui.screens.row_pacer.pending_rows().size() == 2, "会话更替夹具已入队(前提:%d 行)〔wr4〕" % main.ui.screens.row_pacer.pending_rows().size())
|
||
main.begin_session(false)
|
||
check(main.ui.screens.row_pacer.pending_rows().is_empty(), "会话更替后待播队列清零(_begin_session 内机制 flush)〔wr4〕")
|
||
_finish()
|
||
|
||
|
||
func _count_row(row: Dictionary, arr: Array) -> void:
|
||
arr.append(row.duplicate(true))
|
||
|
||
|
||
# FX 排播链断言(逐行文字列表退役后的行落地面——〔退役改 V-退役卡 R2〕):
|
||
# 判据出处: design/ux/battle.md:439 动效:结算逐行兑现为信息载体不可整体跳过,可加倍(减动效只调时长)
|
||
# 判据出处: design/gdd/08-Alpha版本标准.md:154 战斗演出链(弹道 / 受击 / 血条 / 灰化)在役
|
||
# 真实行集(夹具采样的 step_row 载荷)驱动独立 RowPacer,断言:
|
||
# ① 每行恰好一拍站桩反馈(on_step_row_fx 计数=行数);② 弹道/标出调用计数与行型分布逐位对上
|
||
# (期望从行集独立推导、实际从记参假件读——不镜像实现);③ 断言前提=行集非空(非空自检)。
|
||
# 记参假棋盘 FX(highlight 分支载体——perfect sweep / seal hit 弹道只记不演)
|
||
class FakeBoardFx extends BoardFxView:
|
||
var calls: Array[String] = []
|
||
|
||
func play_perfect_sweep() -> void:
|
||
calls.append("perfect_sweep")
|
||
|
||
func play_seal_hit_projectile(_heavy: bool) -> void:
|
||
calls.append("seal_hit_projectile")
|
||
|
||
|
||
# 记参假 toast(skill_unlock 行载体)
|
||
class FakeToast extends ToastStack:
|
||
var texts: Array[String] = []
|
||
|
||
func show_toast(text: String, _tone: String = "info") -> void:
|
||
texts.append(text)
|
||
|
||
|
||
# 记参假棋盘区(_consume_row highlight 分支的 fx/toast 载体)
|
||
class FakeBoardZone extends BoardZoneView:
|
||
var board_fx := FakeBoardFx.new()
|
||
var toast_stack := FakeToast.new()
|
||
|
||
|
||
func _assert_fx_chain(main: Node, rows_seen: Array) -> void:
|
||
var rp = RowPacerScript.new()
|
||
var fstage := FakeStage.new(null, root.get_node("/root/ConfigDB").presentation.battle_layout)
|
||
var ffx := FakeFx.new()
|
||
var fzone := FakeBoardZone.new(root.get_node("/root/ConfigDB").presentation.battle_layout)
|
||
fzone.fx = fzone.board_fx
|
||
fzone.toast = fzone.toast_stack
|
||
rp.stage = fstage
|
||
rp.board_view = main.ui.battle.board_zone.board_view
|
||
rp.settlement_fx = ffx
|
||
rp.board_zone = fzone
|
||
rp.refresh_request = func() -> void: pass
|
||
rp.pump_ended = func() -> void: pass
|
||
for row in rows_seen:
|
||
rp.enqueue(row.duplicate(true))
|
||
rp.flush()
|
||
check(rows_seen.size() > 0, "FX 链断言前提:行集非空(非空自检)")
|
||
check(fstage.fx_rows.size() == rows_seen.size(),
|
||
"每行恰好一拍站桩反馈(on_step_row_fx %d/%d)〔退役改 V-退役卡 R2〕" % [fstage.fx_rows.size(), rows_seen.size()])
|
||
# 期望计数从行集独立推导(演出语义=0.4 硬契约:伤害行弹道+数字、治疗行弹道+数字、效果行色环)
|
||
var exp_projectile := 0
|
||
var exp_mark := 0
|
||
var exp_pulse := 0
|
||
for row in rows_seen:
|
||
var t := String(row.get("type", ""))
|
||
var tgt := String(row.get("target", ""))
|
||
if t == "damage":
|
||
exp_projectile += 1
|
||
exp_mark += 1
|
||
elif t == "heal" and (tgt == "hero" or tgt == ""):
|
||
exp_projectile += 1
|
||
exp_mark += 1
|
||
elif t == "heal":
|
||
exp_mark += 1
|
||
elif t == "effect":
|
||
exp_pulse += 1
|
||
var got_projectile := 0
|
||
var got_mark := 0
|
||
var got_pulse := 0
|
||
for c in ffx.calls:
|
||
match String(c[0]):
|
||
"projectile": got_projectile += 1
|
||
"mark": got_mark += 1
|
||
"pulse": got_pulse += 1
|
||
check(got_projectile == exp_projectile,
|
||
"跨区弹道计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_projectile, exp_projectile])
|
||
check(got_mark == exp_mark,
|
||
"结算标出数字计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_mark, exp_mark])
|
||
check(got_pulse == exp_pulse,
|
||
"状态色环计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_pulse, exp_pulse])
|
||
# highlight 三路(完美封印横幅=高光行非解锁技;封印命中加重弹道=seal_hit 高亮键不限行型;
|
||
# Boss 解锁技名 toast=skill_unlock 键)——期望按行协议语义独立推导
|
||
var exp_sweep := 0
|
||
var exp_seal_hit := 0
|
||
var exp_toast := 0
|
||
for row in rows_seen:
|
||
var t := String(row.get("type", ""))
|
||
var hl := String(row.get("highlight", ""))
|
||
if t == "highlight" and hl != "skill_unlock":
|
||
exp_sweep += 1
|
||
elif hl == "seal_hit":
|
||
exp_seal_hit += 1
|
||
if hl == "skill_unlock":
|
||
exp_toast += 1
|
||
check(fzone.board_fx.calls.count("perfect_sweep") == exp_sweep,
|
||
"完美封印横幅计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.board_fx.calls.count("perfect_sweep"), exp_sweep])
|
||
check(fzone.board_fx.calls.count("seal_hit_projectile") == exp_seal_hit,
|
||
"封印命中弹道计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.board_fx.calls.count("seal_hit_projectile"), exp_seal_hit])
|
||
check(fzone.toast_stack.texts.size() == exp_toast,
|
||
"解锁技名 toast 计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.toast_stack.texts.size(), exp_toast])
|
||
rp.free()
|
||
|
||
|
||
func _finish() -> void:
|
||
# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组)
|
||
for p in ["user://test_pacing_queue_save.json", "user://test_pacing_queue_save.json.tmp"]:
|
||
if FileAccess.file_exists(p):
|
||
DirAccess.remove_absolute(p)
|
||
if _problems.is_empty():
|
||
print("[pacing_queue_test] PASS — 排空零分叉/零丢行/延迟双态 全绿")
|
||
quit(0)
|
||
else:
|
||
for p in _problems:
|
||
push_error("[pacing_queue_test] FAIL — " + p)
|
||
print("[pacing_queue_test] FAIL — " + p)
|
||
quit(1)
|