diff --git a/game/core/battle_fx_host.gd b/game/core/battle_fx_host.gd index 2929dcc5..5ffa6955 100644 --- a/game/core/battle_fx_host.gd +++ b/game/core/battle_fx_host.gd @@ -23,7 +23,7 @@ var damage: DamageController var emit_cb: Callable # 宿主 _emit_row——行协议面单点(5 参 head 束形:head/ri/display/value/extra) var facts_cb: Callable # 宿主 _fx_host_fact——round_index/battle_index/seals_count/nest_count/hp_ratio 五事实宿主侧单点 var grant_cb: Callable = Callable() # 揭示兑付回调=RewardGrant 授予面(bind_grant 注入;构造序解法——本件对授予面零强引用) -var engine: EffectEngine # 效果引擎(原编排器 _fx——宿主 _init 经此转注 damage/action_economy/reward_economy 三 set_fx) +var engine: EffectEngine # 效果引擎(原编排器 _fx——宿主 _init 经此转注 damage/action_economy 两 set_fx;reward_economy 经 luck_bias_for 查询口取回偏运合计) var counters_ctx: EffectContext # 四桶引用束(round/battle 两桶本件自有;adventure/level 桶 begin_battle mount_buckets 挂引用) var ac := {"base": -1, "round": 0, "battle": 0} # ac 束(base=主角 AC 基线快照 enc.hero_armor_class,-1=表值兜底未解析;round=ac_bonus 本轮累积器〔封印桩,round_start 清零〕;battle=本战累积器〔石肤,cap 封顶,begin_battle 清零〕) var _fx_bonus_delta := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 本战 bonus_delta 累积(战末宿主 bonus_delta_drain 读后清零) @@ -55,6 +55,17 @@ func fx_emit(event: String, payload: Dictionary) -> Array: return engine.dispatch(counters_ctx, event, payload, _fx_fact) +# 偏运权重查询(reward_roll 发射面——bias_rewards 词汇在本层收口,掷格域只见整数)。 +# 三参形 dispatch(不带 facts:本事件订阅行 when 事实面不参与,命中集与缝拆前逐字同); +# 发射时点由掷格件在开流之后、权重装配之前调用;零命中=0=原行集零扰动。 +func luck_bias_for(cell_count: int) -> int: + var total := 0 + for h in engine.dispatch(counters_ctx, "reward_roll", {"polarity": "positive", "cell_count": cell_count}): + if String(h.get("kind", "")) == "bias_rewards": + total += int(h.get("value", 0)) + return total + + # round_start 发射+施加(grant_ap mode:queue=当轮入队,行动经济轮切换即兑现;计数器 round_start # 重置点在 dispatch 内生效。调用点两处:begin_battle 首轮 / 轮始段续轮。payload 携 round〔宿主侧 # 事实单点〕+ap_exhausted_last〔一滴不剩 when 面〕;发射前 ac_bonus 本轮累积器清零并同步主角 AC) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index c2a77825..e3497112 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -211,6 +211,7 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: _enemy_system = UnitContainer.new(db) _status_effects = StatusContainer.new(db) _reward_economy = RewardEconomy.new(db, rng) + _board_generator.fatal_handler = _fatal; _reward_economy.fatal_handler = _fatal # 协作件违约→本类 _fatal 封印(单点接线) _damage = DamageController.new(db, rng, _enemy_system, _status_effects) _damage.emit_row_cb = _emit_row # 行协议归属本类(9 类行协议 + 来源断言不外泄) _fx_host = BattleFxHost.new({"db": db, "rng": rng, "minesweeper": minesweeper, "action_economy": action_economy, @@ -221,7 +222,7 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: _fx_host.bind_grant(_reward_grant.reveal_redeem) # 构造序禁环:两件建齐后接线(fx 件对授予面走注入回调) _damage.set_fx(_fx_host.engine, _fx_host.counters_ctx) # 效果引擎注入(attack_commit/clamp_check/armor_roll/kill/damage_taken 发射面) action_economy.set_fx(_fx_host.engine, _fx_host.counters_ctx) # dig_paid/charge_full 发射面 - _reward_economy.set_fx(_fx_host.engine, _fx_host.counters_ctx) # reward_roll 发射面 + _reward_economy.luck_bias_query = _fx_host.luck_bias_for # 偏运查询口注入(reward_roll 发射归 fx 宿主) minesweeper.cell_revealed.connect(_on_cell_revealed) minesweeper.seal_judged.connect(_on_seal_judged) minesweeper.round_end_board.connect(_on_round_end) diff --git a/game/core/board_generator.gd b/game/core/board_generator.gd index c5d4db2b..9e338fe6 100644 --- a/game/core/board_generator.gd +++ b/game/core/board_generator.gd @@ -7,6 +7,10 @@ extends RefCounted const METRIC_VERSION := 1 +# 违约中止通道——缺省空 Callable 退化 push_error+assert(现行为逐字保持);组装层注入 +# orchestrator._fatal,使 release 剥 assert 后仍经 battle_result 封印真中止。 +var fatal_handler: Callable = Callable() + # ==================== 生成入口 ==================== @@ -395,4 +399,6 @@ func _lex_less(a: Array, b: Array) -> bool: func _fatal(msg: String) -> void: push_error("[棋盘生成/fatal] " + msg) + if fatal_handler.is_valid(): + fatal_handler.call(msg) # 组装层注入 orchestrator._fatal(封印传导,不绕第二中止通道) assert(false, msg) diff --git a/game/core/reward_economy.gd b/game/core/reward_economy.gd index 3f820360..e02515f2 100644 --- a/game/core/reward_economy.gd +++ b/game/core/reward_economy.gd @@ -7,6 +7,9 @@ extends RefCounted var db: ConfigDb var rng: RngServiceCore +# 违约中止通道——缺省空 Callable 退化 push_error+assert(现行为逐字保持);组装层注入 +# orchestrator._fatal,使 release 剥 assert 后仍经 battle_result 封印真中止。 +var fatal_handler: Callable = Callable() var _phase := "unrolled" # unrolled → ready → retired var _eligible: Array[Dictionary] = [] # [{cell}] 行主序规范序 var _mapping := {} # Vector2i → reward_id(正向行身份——格面资源类型由 cell_type 承载) @@ -16,14 +19,9 @@ var _granted := {} # Vector2i → 快照(重复受理幂等返 var _clear_identity := "" var _clear_granted := false var _clear_snapshot := {} -# 效果引擎(reward_roll 发射面;偏运硬币 bias_rewards 订阅面) -var _fx: EffectEngine = null -var _ctx: EffectContext = null - - -func set_fx(fx: EffectEngine, ctx: EffectContext) -> void: - _fx = fx - _ctx = ctx +# 偏运权重查询口(reward_roll 发射归 BattleFxHost 效果层——掷格域不持效果引擎/上下文; +# 参数=本次可掷格数、返回=偏运权重加算量、缺省空回调=恒 0=原行集零扰动) +var luck_bias_query: Callable = Callable() func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: @@ -60,13 +58,11 @@ func roll_board(eligible: Array) -> bool: _clear_granted = false _clear_snapshot = {} var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(格批 + 清图批尾 + 区间行追加取号) - # reward_roll 发射+施加(格面资源掷定、摇号前权重合成层: - # bias_rewards 订阅命中合计=偏运权重加算(行 value_from magnitude 定影);零命中=原行集零扰动) + # reward_roll 发射归 BattleFxHost(偏运硬币 bias_rewards 订阅命中合计经注入查询口取回); + # 零命中/未接线=0=原行集零扰动 var luck_bias := 0 - if _fx != null: - for h in _fx.dispatch(_ctx, "reward_roll", {"polarity": "positive", "cell_count": list.size()}): - if String(h.get("kind", "")) == "bias_rewards": - luck_bias += int(h.get("value", 0)) + if luck_bias_query.is_valid(): + luck_bias = luck_bias_query.call(list.size()) var cell_rows: Array[Variant] = _biased_cell_rows(luck_bias) # cell 池正向行 weight>0(偏运硬币装配时稀有正向 kinds 权重 +N——池结构零触碰) var clear_rows: Array[Variant] = _pool_rows("clear_default") # ① 身份批:每非封锁格恰一类型身份(雷安同池——同一行集同一权重分布) @@ -365,4 +361,6 @@ func _diag(msg: String) -> void: func _fatal(msg: String) -> void: push_error("[地图战斗资源/fatal] " + msg) + if fatal_handler.is_valid(): + fatal_handler.call(msg) # 组装层注入 orchestrator._fatal(封印传导,不绕第二中止通道) assert(false, msg) diff --git a/game/core/rng_service.gd b/game/core/rng_service.gd index 65ffccee..1bc2e2b2 100644 --- a/game/core/rng_service.gd +++ b/game/core/rng_service.gd @@ -24,6 +24,9 @@ var _master_seed: int = -1 # -1 = 未播种(取数即致命, var master_seed: int: get: return _master_seed var _counter: int = 0 # 全局取数计数器(唯一账本——快照契约 = 主种子 + 本计数器) +# 违约通道(未知用途标签/未播种取数两分支共用)——缺省空 Callable 退化 push_error+assert(现行为逐字保持); +# 注入后供负面套件断言「handler 被调用」(assert 只中断本函数,debug 下两态不可判别——记录位是唯一可观测面)。 +var violation_handler: Callable = Callable() # 事件日志(丙案新增):每冒险一会话,逐次取数落一行(scope/用途标签/派生种子)——不致命、失败静默降级 var _log_path := "" var _log_fa: FileAccess = null @@ -100,10 +103,16 @@ func pure_stream(seed_val: int, scope: int) -> RandomNumberGenerator: func open_stream(purpose: String) -> RandomNumberGenerator: if not PURPOSE_TAGS.has(purpose): push_error("[RngService] 未知用途标签: %s" % purpose) + if violation_handler.is_valid(): + violation_handler.call(purpose) assert(false, "未知用途标签(标签词表封闭,EC12)") + return null # release 剥 assert 后经调用方空引用即刻崩=fail-fast 传导(合法路径不可达) if _master_seed < 0: push_error("[RngService] 未设 master_seed 即取数(EC7——冒险结构 未接线的显式暴露)") + if violation_handler.is_valid(): + violation_handler.call(purpose) assert(false, "未播种取数致命(EC7)") + return null # 同上(EC7 通道——release 下禁静默发号) var scope: int = _counter _counter = scope + 1 var rg := RandomNumberGenerator.new() diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 4aca8a09..e4adf790 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -173,6 +173,7 @@ func _replay_locked_click() -> void: # 会话生命周期:_ready 建闲时态(永非 null——全组合根零空判责), # 开局/续玩/调试两层各建新会话,旧会话整节点 queue_free()(帧末回收;新节点先挂树无真空窗)。 func _begin_session(adventure_session: bool) -> void: + _row_pacer.flush() # 会话更替即清队(待播行不住局态——机制保证,不靠时序约定) if run_scope != null: run_scope.queue_free() run_scope = RunScope.new() @@ -308,7 +309,7 @@ func _start_adventure_from_menu() -> void: #(确认才删、取消只关窗——裁决 3)。"new_adventure" 消费方已随卡② 主菜单确认链摘除退役 #(mn6/V5——主菜单零确认残留),分派机制保留(卡④ 到位接入第三消费方不返工)。 func _on_confirm_accepted() -> void: - confirm_dialog.close() + _close_modal(confirm_dialog) # 模态栈单口 match _confirm_consumer: "delete_slot": save_manager.delete_slot(_confirm_consumer_slot) @@ -317,7 +318,7 @@ func _on_confirm_accepted() -> void: func _on_confirm_declined() -> void: - confirm_dialog.close() # 只关窗(取消无状态变更——裁决 3:取消不删) + _close_modal(confirm_dialog) # 只关窗(取消无状态变更——裁决 3:取消不删);关侧恢复归 ModalFlow _confirm_consumer = "" @@ -718,6 +719,8 @@ func _on_board_action(cell: Vector2i, kind: String) -> void: if treasure_sel != 0 and kind == "dig": _treasure_ui.attempt_use(treasure_sel, cell) return + if run_scope.settlement == null: + return # 入口显式空防护(双保险——锁谓词正常态已挡,防锁判据异常时崩于空引用) var outcomes: Array[Variant] = run_scope.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) @@ -745,6 +748,8 @@ func _on_stop_pressed() -> void: if _input_locked(): return # stop 经批次提交(排雷玩法 D1×6①——不直调 行动经济) + if run_scope.settlement == null: + return # 入口显式空防护(同棋盘动作入口——零成本双保险) var outcomes: Array[Variant] = run_scope.settlement.minesweeper.settle_batch([{"action": "stop"}]) logger.log_action("stop", Vector2i(-1, -1), not outcomes.is_empty() and bool(outcomes[0].accepted)) if not outcomes.is_empty() and String(outcomes[0].get("outcome", "")) == "voided": diff --git a/game/presentation/main_overlay_flow.gd b/game/presentation/main_overlay_flow.gd index ce9755d4..3b0337d6 100644 --- a/game/presentation/main_overlay_flow.gd +++ b/game/presentation/main_overlay_flow.gd @@ -85,6 +85,7 @@ static func _on_save_select_load(root: Variant, slot: int) -> void: static func _on_save_select_delete(root: Variant, slot: int) -> void: root._confirm_consumer = "delete_slot" root._confirm_consumer_slot = slot + root._open_modal(root.confirm_dialog) # 模态栈单口(显示编排/挂起兜底/遮挡记账——与其余覆盖层同口) root.confirm_dialog.open("删除存档位 %d?删除后该档不可恢复。" % slot, "删除", "取消") @@ -108,18 +109,11 @@ static func _sync_status_entry(root: Variant) -> void: if root.status_panel == null or root._modals == null or root.board_zone == null or root.level_screen == null: return var map_on: bool = root.level_screen.visible - var battle_live: bool = root.run_scope.level_active or root.run_scope.settlement != null # 调试两层无 level_active——settlement 判据补位 root.board_zone.status_badge.visible = root.run_scope.level_active and not map_on root.level_screen.status_badge.visible = map_on # 置灰判据为排除式(调试浮窗开态不计入:对局中浮窗开着时角标仍可点) root.board_zone.status_badge.disabled = root._modal_flow.badge_greyed_battle() # 互斥域谓词 root.level_screen.status_badge.disabled = root._modal_flow.badge_greyed_map() - # 状态角标判据(同上——重复语句在案): - # 战斗屏=对局内(冒险或调试两层)且地图屏未激活;地图屏=随屏;开始菜单零角标 - root.board_zone.status_badge.visible = root.run_scope.level_active and not map_on - root.level_screen.status_badge.visible = map_on - root.board_zone.status_badge.disabled = root._modal_flow.badge_greyed_battle() # 互斥域谓词 - root.level_screen.status_badge.disabled = root._modal_flow.badge_greyed_map() # 调试浮层角标(全局浮层任何 UI 不遮挡——可见性不随场景变,只抬 offset # 避让:地图屏底栏 85px 常驻,角标抬至底栏上方 16px;其余场景右下角 16px 贴底) root.debug_badge.offset_bottom = -101.0 if map_on else -16.0 diff --git a/game/presentation/views/confirm_dialog.gd b/game/presentation/views/confirm_dialog.gd index a841c4fe..b7816983 100644 --- a/game/presentation/views/confirm_dialog.gd +++ b/game/presentation/views/confirm_dialog.gd @@ -37,8 +37,8 @@ func open(text: String, confirm_label: String, cancel_label: String = "取消") _text.text = text _confirm_btn.text = confirm_label _cancel_btn.text = cancel_label - # z 序纪律:树序由组合根构建序保证——本弹窗在 main._build_ui 尾部 - # add_child(开始层/过场层之后 = 子级最末,登记序 = 树序 = 绘制序恒最上) + # z 序纪律:层序唯一事实源=main.LAYER_CONTRACT(树序与登记序同源派生);开合单口 + # 归组合根 ModalFlow(挂起兜底/遮挡记账/开侧同步)——本类只管文案与显示,不自行抬层 visible = true diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index 05f632e4..cd9cbe94 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -253,12 +253,18 @@ func _ready() -> void: elif not FileAccess.file_exists(csel_tpl % 2): _problems.append("[savesel] sl7 确认前文件已被删(一次点击即删违契)") else: + if not main._modal_flow.stack._occluded.has(main.confirm_dialog): + _problems.append("[savesel] sl7 确认窗未经模态栈开(直开旁路——wr2 单口失守)") + elif not main._input_locked(): + _problems.append("[savesel] sl7 确认窗开时输入锁未生效(锁域失守——wr2)") main.confirm_dialog.cancelled.emit() # 取消路径(真实信号——cancelled 钮同源) await get_tree().process_frame if main.confirm_dialog.is_open() or not FileAccess.file_exists(csel_tpl % 2): _problems.append("[savesel] sl7 取消路径失守(应只关窗、文件仍在)") elif main._confirm_consumer != "": _problems.append("[savesel] sl8 取消后消费方状态量未清(分派面残留)") + if main._modal_flow.stack._occluded.has(main.confirm_dialog): + _problems.append("[savesel] sl7 取消后确认窗遮挡账未清(关侧未走模态栈——wr2)") del_btn.pressed.emit() # 再开窗走确认路径 await get_tree().process_frame main.confirm_dialog.confirmed.emit() # 确认路径(真实信号——confirmed 钮同源) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 37997467..90f3f67b 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -48,6 +48,7 @@ func _suites() -> Array: # 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。 {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 秘宝双表校验 {"path": "res://tests/unit/data_config/counter_prefix_fatal_test.gd", "expect": 1, "negative": true}, # 卡② V9 前缀合法性+引用闭合加载致命 + {"path": "res://tests/unit/core/run_time_fatal_test.gd", "expect": 1, "negative": true}, # 卡② wr5/wr6/wr7 运行期违约通道(fatal 封印传导/未知标签/未播种) {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/table_structure_guard_test.gd", "expect": 0}, # 表结构守卫宿主(adventure 配额与同调目录/enemy 带键行序/AC 单轨与掷点参数/variation 门控互锁/difficulty 修饰/reward 九型/meta 行族) {"path": "res://tests/unit/data_config/rng_stream_boot_test.gd", "expect": 0}, # 启动初始化与统一 RNG 流宿主(data/tables 加载链/统一流黄金向量 m=42/derive_at 恒等/open_stream 取号序/new_adventure 计数器重置——批 B 自 smoke_all 迁入;源 :70 事件日志落盘条按裁决一转办集成宿主) diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd index ecf61af0..5ea2ff31 100644 --- a/game/tests/unit/battle_settlement/variant_placement_test.gd +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -242,6 +242,25 @@ func _smoke_global_variant() -> void: eco.board_skew = 50 var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows(0)) Kit.check("域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)", eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0) + # wr10 偏运判定收口+注入接线(p5 缝拆)——判定面夹具白盒装载(现网 game/data/tables/ 无产出 + # bias_rewards 的数据行,先例=tree_rules_v11_test 读 _triggers);命中数前置写在消息里防空扫描面假绿。 + var wrng = load("res://core/rng_service.gd").new() + wrng.set_master_seed(42) + var woc = load("res://core/battle_orchestrator.gd").new(_db, wrng) + var wfx = woc.fx_host() + var empty_bias: int = wfx.luck_bias_for(4) + wfx.engine._push_trigger("feat", "wr10_fixture", {"on": "reward_roll", + "do": {"kind": "bias_rewards", "value": 2}}, {}) + var hit_rows: int = wfx.engine._triggers.size() + var loaded_bias: int = wfx.luck_bias_for(4) + Kit.check("wr10①判定面:空订阅=%d / 装载后命中 %d 行 → luck_bias_for(4)=%d(期望 0/1/2;命中 0 即判据空转)" % [empty_bias, hit_rows, loaded_bias], + empty_bias == 0 and hit_rows == 1 and loaded_bias == 2) + Kit.check("wr10②接线面:组装点注入的查询口有效且与宿主查询口同源", + woc._reward_economy.luck_bias_query.is_valid() and woc._reward_economy.luck_bias_query == wfx.luck_bias_for) + woc._reward_economy.board_skew = 0 + var wbias: int = int(woc._reward_economy.luck_bias_query.call(4)) + Kit.check("wr10③权重生效面:注入回调取回 %d → 偏运池合计 77+4*2(实测 %d)" % [wbias, woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias))], + wbias == 2 and woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias)) == 77 + 4 * 2) # 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照) var nrng = load("res://core/rng_service.gd").new() nrng.set_master_seed(42) diff --git a/game/tests/unit/core/run_time_fatal_test.gd b/game/tests/unit/core/run_time_fatal_test.gd new file mode 100644 index 00000000..88ae23a2 --- /dev/null +++ b/game/tests/unit/core/run_time_fatal_test.gd @@ -0,0 +1,125 @@ +# 运行期违约通道负面套件(独立进程,期望退出码 1;回归态退出码 2) +# 卡② 三测试点:①wr5 协作件 _fatal 经组装点传导 orchestrator 封印(p5)+缺省形态不外溢; +# ②wr6 未知用途标签违约通道(p6);③wr7 未播种取数违约通道(p7)。 +# 载体纪律(卡面 §7):注入只经编排器组装点——本文件不出现该注入成员的名称字样(防撤销证明②空转); +# 三测试点各用新建 RngService 实例(播种断言 _counter == 0);回归态判据只取可观测面—— +# handler 记录位与封印是否落。return 位在 debug 下两态不可判别(assert 先中断函数本体), +# 走静态判据+代码评审注记,禁写成运行期断言。 +# 违约路径必产 SCRIPT ERROR(assert 失败不可捕获)——故本件 manifest 登记 negative=true(test-conventions §5)。 +# 运行:godot --headless --path game --script res://tests/unit/core/run_time_fatal_test.gd +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const RngScript := preload("res://core/rng_service.gd") +const BoardGeneratorScript := preload("res://core/board_generator.gd") +const RewardEconomyScript := preload("res://core/reward_economy.gd") +const OrchestratorScript := preload("res://core/battle_orchestrator.gd") + +const TAG_OK := "board_gen" # 在册合法标签(PURPOSE_TAGS 含;wr7 必须用合法标签——未知标签检查排在播种检查之前) + + +func _init() -> void: + var db = ConfigDBScript.new() + if not db.load_tables("res://data/tables", true): + push_error("[run_time_fatal_test] 表加载失败——夹具不可用(回归)") + quit(2) + return + # 正向/缺省形态先跑完再触发违约(防 SCRIPT ERROR 混入正向判定) + if not _default_form(db): + quit(2) + return + if not _seal_propagation(db): + quit(2) + return + if not _unknown_tag_channel(): + quit(2) + return + if not _unseeded_channel(): + quit(2) + return + print("[run_time_fatal_test] 三点全绿:封印传导+未知标签通道+未播种通道〔卡② wr5/wr6/wr7〕") + quit(1) + + +# 缺省形态(契约 2):不接组装点的协作件违约只落自身 push_error+assert,不外溢封印 +func _default_form(db) -> bool: + var rng = RngScript.new() + rng.set_master_seed(3) + var oc = OrchestratorScript.new(db, rng) + var bare_bg = BoardGeneratorScript.new() + bare_bg._fatal("缺省形态违约①(未接组装点——不应外溢封印)") + var bare_re = RewardEconomyScript.new(db, rng) + bare_re._fatal("缺省形态违约②(未接组装点——不应外溢封印)") + if oc.battle_result == "fatal_error": + push_error("[run_time_fatal_test/缺省] 未接组装点的违约外溢到编排器封印(缺省形态破)") + return false + # 合法路径对照(不注入 handler):播种后取在册标签照常发号 + var rng_ok = RngScript.new() + rng_ok.set_master_seed(3) + var rg = rng_ok.open_stream(TAG_OK) + if rg == null or rng_ok.counter() != 1: + push_error("[run_time_fatal_test/缺省] 合法取号路径被改变(rg=%s counter=%d)" % [str(rg), rng_ok.counter()]) + return false + return true + + +# ①wr5(p5):两协作件违约经组装点接线传导 → orchestrator.battle_result 落 "fatal_error" +func _seal_propagation(db) -> bool: + var rng_a = RngScript.new() + rng_a.set_master_seed(5) + var oc_bg = OrchestratorScript.new(db, rng_a) + oc_bg._board_generator._fatal("测试违约①棋盘生成") + if oc_bg.battle_result != "fatal_error": + push_error("[run_time_fatal_test/wr5] 棋盘生成违约未传导封印(battle_result=%r)" % oc_bg.battle_result) + return false + var rng_b = RngScript.new() + rng_b.set_master_seed(5) + var oc_re = OrchestratorScript.new(db, rng_b) + oc_re._reward_economy._fatal("测试违约②地图战斗资源") + if oc_re.battle_result != "fatal_error": + push_error("[run_time_fatal_test/wr5] 地图战斗资源违约未传导封印(battle_result=%r)" % oc_re.battle_result) + return false + return true + + +# ②wr6(p6):未知用途标签 → handler 记录位为真(判据主体)+拿不到可用 RNG;合法标签对照另行验过 +func _unknown_tag_channel() -> bool: + var rng_bad = RngScript.new() + rng_bad.set_master_seed(7) + if not _inject_handler(rng_bad, "wr6"): + return false + var recorded: Array = [] + rng_bad.violation_handler = func(p: String) -> void: recorded.append(p) + var rg = rng_bad.open_stream("bogus_purpose") + if recorded.size() != 1 or String(recorded[0]) != "bogus_purpose": + push_error("[run_time_fatal_test/wr6] 未知标签违约通道未记录(recorded=%s)" % str(recorded)) + return false + if rg != null: + push_error("[run_time_fatal_test/wr6] 未知标签竟返回可用 RNG(违约被放行)") + return false + return true + + +# ③wr7(p7):未播种取数 → handler 记录位为真+拿不到可用 RNG;同标签播种对照另行验过 +func _unseeded_channel() -> bool: + var rng_bad = RngScript.new() # 不播种(_master_seed 缺省 -1) + if not _inject_handler(rng_bad, "wr7"): + return false + var recorded: Array = [] + rng_bad.violation_handler = func(p: String) -> void: recorded.append(p) + var rg = rng_bad.open_stream(TAG_OK) + if recorded.size() != 1 or String(recorded[0]) != TAG_OK: + push_error("[run_time_fatal_test/wr7] 未播种违约通道未记录(recorded=%s)" % str(recorded)) + return false + if rg != null: + push_error("[run_time_fatal_test/wr7] 未播种竟返回可用 RNG(EC7 违约被放行)") + return false + return true + + +# 成员在位前提检查(成员被移除时走干净退出 2,不落「赋值不存在的属性」式中途中断) +func _inject_handler(rng, label: String) -> bool: + if not (rng.get("violation_handler") is Callable): + push_error("[run_time_fatal_test/%s] violation_handler 成员缺失(p6/p7 形态未实装——回归)" % label) + return false + return true diff --git a/game/tests/unit/core/run_time_fatal_test.gd.uid b/game/tests/unit/core/run_time_fatal_test.gd.uid new file mode 100644 index 00000000..80a187f8 --- /dev/null +++ b/game/tests/unit/core/run_time_fatal_test.gd.uid @@ -0,0 +1 @@ +uid://cer3wlsqhjy7i diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index aa1a3dfa..25178807 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -139,6 +139,14 @@ func _init() -> void: "跨区弹道按行 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._row_queue.size() == 2, "会话更替夹具已入队(前提:%d 行)〔wr4〕" % main._row_queue.size()) + main._begin_session(false) + check(main._row_queue.is_empty(), "会话更替后待播队列清零(_begin_session 内机制 flush)〔wr4〕") _finish()