diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 6becbdfc..7c46f0c0 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -142,6 +142,15 @@ func _spend_cost_and_arbitrate(cost: int, clears_map: bool) -> bool: return true +# 封印路径清图事实上报(排雷玩法 _do_seal_place 受理后清图判定成立时调用): +# 轮末仲裁与 request_dig 的 cleared 语义同源——剩余 AP 当场清零作废、不进 RAP(EC7/EC10), +# 同帧 cleared > ap_exhausted 胜出(覆盖写 _last_round_end)。封印不产挖掘计费, +# 清图事实经独立上报口进入仲裁(硬塞 request_dig 会引入假挖掘记账)。 +func notify_cleared() -> void: + _ap = 0 + _finish_round("cleared", "new_board") + + func _spend_and_arbitrate(action_kind: String, clears_map: bool) -> bool: var cost := _cost(action_kind) if _ap < cost: diff --git a/game/core/board_data.gd b/game/core/board_data.gd index fa7651b9..3b06fc2f 100644 --- a/game/core/board_data.gd +++ b/game/core/board_data.gd @@ -43,6 +43,14 @@ func is_safe(c: Vector2i) -> bool: func digit_of(c: Vector2i) -> int: return digits.get(c, -1) +## 格身份事实(封闭枚举):kind = "mine"(digit 恒 -1)| "safe"(digit = 邻雷数)。 +## 封印受理即揭示的消费形态 = 呈现/测量侧按封印态(MinesweeperRules.has_seal)取本读数拼装—— +## 身份事实单点在板数据(is_mine/digit_of 同源),禁消费方各自拼 kind 枚举。 +func cell_identity(c: Vector2i) -> Dictionary: + if is_mine(c): + return {"kind": "mine", "digit": -1} + return {"kind": "safe", "digit": digit_of(c)} + func variant_ids(c: Vector2i) -> Array: return variants.get(c, []) diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 2b32f298..caed1e80 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -621,4 +621,4 @@ func _target_for(rule: String) -> Variant: func _ineffective_row(t: Dictionary, reason: String) -> void: emit_row_cb.call({"type": "reward_ineffective", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index), - "%s 未生效(%s)" % [String(t.display), reason], 0, {"batch_key": String(t.context.get("batch_key", "")), "deferred": true}) + "%s 未生效(%s)" % [String(t.display), reason], 0, {"batch_key": String(t.context.get("batch_key", "")), "deferred": true, "reason": "no_enemy_alive"}) diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index 56b9e75f..f8b18cfc 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -52,12 +52,12 @@ const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open ## fact 条件词汇(封闭枚举——payload 同名键直读面〔fact 取值序 payload ## 优先——effect_engine._fact_value 既有机制〕。new_resource_type=首开资源类型〔地质学家〕; -## is_edge_cell=边缘格〔边陲矿工〕;is_blank_zero=空白连锁〔跳格采掘〕;dug_under_seal=被挖格 -## 当时有封印〔破封一击〕;ap_exhausted_last=上轮 AP 耗尽〔一滴不剩〕;hp_ratio=生命比例〔背水 +## is_edge_cell=边缘格〔边陲矿工〕;is_blank_zero=空白连锁〔跳格采掘〕;ap_exhausted_last=上轮 +## AP 耗尽〔一滴不剩〕;hp_ratio=生命比例〔背水 ## 06 前置条兑付〕;amount=单次伤害量〔保险丝〕;ap_left=停手剩余 AP〔弃图兑换〕) const FACTS := ["matchup", "is_first_dig_of_battle", "terrain_kind", "target_armor_class", "seals_count", "nest_count", "round_index", "counter", - "new_resource_type", "is_edge_cell", "is_blank_zero", "dug_under_seal", + "new_resource_type", "is_edge_cell", "is_blank_zero", "ap_exhausted_last", "hp_ratio", "amount", "ap_left", "pct", "battle_index"] # 关卡域战斗序(1 基,enc 携带;开局姿态组「每关首轮窗」条件面) diff --git a/game/core/minesweeper_rules.gd b/game/core/minesweeper_rules.gd index 78612ef1..bf2bb1dc 100644 --- a/game/core/minesweeper_rules.gd +++ b/game/core/minesweeper_rules.gd @@ -8,8 +8,7 @@ extends RefCounted ## 揭示事实流:每格首次进入已开集恰发一次(已开格零事件)。 ## 载荷键集(四处发射位并集):cell: Vector2i/kind: "safe"|"mine"/digit: int(安全格邻雷数;危险格 -1)/ ## variant_ids: Array(地形变异身份,危险格空集)/channel: "initial"|"dig"|"chain"|"fx_reveal"/ -## resource: bool(格面资源兑付资格:initial=false,其余 true)/dug_under_seal: bool(首格带封印直挖标记—— -## initial 通道不发此键)。 +## resource: bool(格面资源兑付资格:initial=false,其余 true)。 ## 发送时点:initial=install_board 初始展开逐格;dig/chain=_do_dig 揭示闭包内(本格 AP 扣费在其后); ## fx_reveal=fx_reveal 闭包内(零 AP 消耗零相位要求)。 ## 消费方依赖:战斗结算 _on_cell_revealed 以 resource 分流缓冲(fx_reveal 由兑付点直入账,禁二次缓冲)、 @@ -94,14 +93,16 @@ func has_seal(c: Vector2i) -> bool: return _seals.has(c) + func revealed_cells() -> Array: return _revealed.keys() func _safe_remaining() -> int: + # 清图口径 = 已挖 ∪ 已封印(封印受理即揭示改版——封掉的安全格计入完成) var n := 0 for c in _board.safe_cells(): - if not _revealed.has(c): + if not _revealed.has(c) and not _seals.has(c): n += 1 return n @@ -266,25 +267,41 @@ func settle_batch(intents: Array) -> Array: func _classify(intent: Dictionary) -> int: # 0 = C_dig(执行后清图——真模拟,禁计数器捷径);1 = C_stop;2 = C_other + # 清图口径 = 已挖 ∪ 已封印(封印受理即揭示改版——「能清图的挖或封」同归最先类) if intent.get("action", "") == "dig": var c: Vector2i = intent.get("cell", Vector2i(-9, -9)) if _board_face_dig(c) == "" and not _board.is_mine(c): - # 模拟 Chain(O, c) 后清图判定——挖封印格同口径:本格封印视为已隐式退役(其余封印照挡) - var sim_seals: Dictionary[Variant, Variant] = _seals.duplicate() - sim_seals.erase(c) - var chain := _chain_closure(c, sim_seals) # 与真实执行同口径——空封印集捷径会在封印挡连锁时误判 C_dig(排雷玩法:真模拟) + # 模拟 Chain(O, c) 后清图判定——真模拟禁计数器捷径(连锁不越封印,真封印集口径) + var chain := _chain_closure(c, _seals) var remaining := 0 for sc in _board.safe_cells(): - if not _revealed.has(sc) and not chain.has(sc): + if not _revealed.has(sc) and not chain.has(sc) and not _seals.has(sc): remaining += 1 if remaining == 0: return 0 return 2 + if intent.get("action", "") == "seal_place": + return _classify_seal(intent.get("cell", Vector2i(-9, -9))) if intent.get("action", "") == "stop": return 1 return 2 +# 封印意图的清图预判分类(批次语义序「能清图的封」取数面——受理合法性模拟与清图口径模拟) +func _classify_seal(c: Vector2i) -> int: + if not _board.in_bounds(c) or is_revealed(c) or has_seal(c) or _removed.has(c): + return 2 + if _board.is_mine(c): + return 2 # 封危险格不清图(封印受理即揭示——身份当场可见但安全格分母不减) + # 模拟封印受理后清图判定(换装口径:安全格完成 = 已揭示 ∪ 已封印) + var sim_seals: Dictionary[Variant, Variant] = _seals.duplicate() + sim_seals[c] = true + for sc in _board.safe_cells(): + if not _revealed.has(sc) and not sim_seals.has(sc): + return 2 + return 0 + + func _execute(intent: Dictionary) -> Dictionary: match intent.get("action", ""): "dig": @@ -305,16 +322,8 @@ func _do_dig(c: Vector2i) -> Dictionary: var sub := _board_face_dig(c) if sub != "": return _outcome(false, "rejected", "board_face", sub) - # 挖封印格(封印不可逆):本格封印事务内隐式退役——链闭包按「本格无封印、其余封印照挡」 - # 计算(封印格为 0 时连锁照常外扩);request_dig 被拒则封印原地保留(事务性),受理后该封印轮末不判 - # (发射面基线 = 在场封印——标中/标空兑付让位于挖掘本体结局,四情况②④)。 - var was_sealed := has_seal(c) - var eff_seals: Dictionary[Variant, Variant] = _seals - if was_sealed: - eff_seals = _seals.duplicate() - eff_seals.erase(c) - # 真模拟连锁与清图(F2/F1——单一实现两名字) - var chain := _chain_closure(c, eff_seals) + # 真模拟连锁与清图(F2/F1——单一实现两名字;连锁不越封印,挖封印格已被合法性表拒绝) + var chain := _chain_closure(c, _seals) var clears: bool = _cleared_after(chain) # F2 口径:chain_size = |O′ ∖ (O ∪ {c})|——闭包含已开集 O,只数新开格(不含首格) var chain_size := 0 @@ -325,10 +334,8 @@ func _do_dig(c: Vector2i) -> Dictionary: if not action_economy.request_dig(chain_size, clears): return _outcome(false, "rejected", "resource_face", "") # 事务内执行:首格 + 连锁新格揭示(BFS 规范序,零特判);事件 = O′ ∖ O - if was_sealed: - _seals.erase(c) - var reveal_order: Array[Variant] = _chain_reveal_order(c, eff_seals) - _reveal_chain(c, reveal_order, was_sealed) + var reveal_order: Array[Variant] = _chain_reveal_order(c, _seals) + _reveal_chain(c, reveal_order) if action_economy.phase != action_economy.PHASE_ACTIVE: _emit_round_end_seals_and_payload() return _outcome(true, "executed", "", "") @@ -337,8 +344,7 @@ func _do_dig(c: Vector2i) -> Dictionary: ## 连锁揭示域:首格+连锁新格揭示(BFS 规范序——cell_revealed 发射序即链序,整体随段搬迁)。 ## 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带, ## 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。 -## dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印) -func _reveal_chain(c: Vector2i, reveal_order: Array, was_sealed: bool) -> void: +func _reveal_chain(c: Vector2i, reveal_order: Array) -> void: for rc in reveal_order: if _revealed.has(rc): continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|) @@ -348,14 +354,12 @@ func _reveal_chain(c: Vector2i, reveal_order: Array, was_sealed: bool) -> void: cell_revealed.emit({ "cell": rc, "kind": "mine", "digit": -1, "variant_ids": [], "channel": channel, "resource": true, - "dug_under_seal": was_sealed and rc == c, }) else: _revealed[rc] = {"kind": "safe", "digit": _board.digit_of(rc)} cell_revealed.emit({ "cell": rc, "kind": "safe", "digit": _board.digit_of(rc), "variant_ids": _board.variant_ids(rc), "channel": channel, "resource": true, - "dug_under_seal": was_sealed and rc == c, }) @@ -366,9 +370,10 @@ func _board_face_dig(c: Vector2i) -> String: return "not_adjacent" if is_revealed(c): return "target_revealed" + if has_seal(c): + return "target_sealed" # 封印受理即揭示、不可再挖开(封印承诺 = 对该格的最终处置——改判路径已退役) if _removed.has(c): return "target_removed" # 中立移除格不可交互(拒绝先于消耗,pattern 1 board_face 腿) - # 封印不拦挖掘(封印不可逆):有封印格直接挖 = 对承诺的兑现,兑付改判归 战斗结算。 return "" @@ -387,17 +392,14 @@ func fx_reveal(c: Vector2i) -> bool: cell_revealed.emit({ "cell": rc, "kind": "safe", "digit": _board.digit_of(rc), "variant_ids": _board.variant_ids(rc), "channel": "fx_reveal", "resource": true, - "dug_under_seal": false, }) return true -## 挖一格→真实翻开集只读预览(sim 信息面出口——与 _classify 同口径:本格封印视为已隐式退役、 -## 其余封印照挡;复用 _chain_closure 闭包;零状态写入、零事件发射) +## 挖一格→真实翻开集只读预览(sim 信息面出口——与 _classify 同口径:连锁不越封印、 +## 真封印集口径;复用 _chain_closure 闭包;零状态写入、零事件发射) func preview_reveal(c: Vector2i) -> Array[Vector2i]: - var sim_seals := _seals.duplicate() - sim_seals.erase(c) - var closure := _chain_closure(c, sim_seals) + var closure := _chain_closure(c, _seals) var out: Array[Vector2i] = [] for cc in closure.keys(): if cc != c and not _revealed.has(cc): @@ -436,34 +438,44 @@ func _chain_reveal_order(start: Vector2i, seal_set: Dictionary) -> Array: func _cleared_after(chain: Dictionary) -> bool: + # 清图口径 = 已挖 ∪ 已封印(与 _safe_remaining 同源——私有共享口径单点,禁第三处重复实现) for c in _board.safe_cells(): - if not _revealed.has(c) and not chain.has(c): + if not _revealed.has(c) and not chain.has(c) and not _seals.has(c): return false return true # ---- 封印 ---- -func _do_seal_place(c: Vector2i) -> Dictionary: - var sub := "" +# 封印棋盘面合法性(board_face 拒绝情形序:目录外/不相邻/已开/已有封印/已移除——与挖掘合法性同构分域) +func _seal_face_reject(c: Vector2i) -> String: if not _board.in_bounds(c): - sub = "out_of_catalog" - elif not (seal_range_free or _adjacent_to_hero(c)): - sub = "not_adjacent" # 信标网络装配时豁免(封印不限相邻——封印动作仍按标准价计费,规则放宽仅可达性) - elif is_revealed(c): - sub = "target_revealed" - elif has_seal(c): - sub = "seal_present" - elif _removed.has(c): - sub = "target_removed" # 中立移除格不可交互 + return "out_of_catalog" + if not (seal_range_free or _adjacent_to_hero(c)): + return "not_adjacent" # 信标网络装配时豁免(封印不限相邻——封印动作仍按标准价计费,规则放宽仅可达性) + if is_revealed(c): + return "target_revealed" + if has_seal(c): + return "seal_present" + if _removed.has(c): + return "target_removed" # 中立移除格不可交互 + return "" + + +func _do_seal_place(c: Vector2i) -> Dictionary: + var sub := _seal_face_reject(c) if sub != "": return _outcome(false, "rejected", "board_face", sub) if not action_economy.try_spend("seal_place"): return _outcome(false, "rejected", "resource_face", "") _seals[c] = true # 封印动作零格子事件外发(发射面封闭;反馈经调用返回值命令式驱动) + if _cleared_after({}): + # 封印受理即揭示改版:封掉最后安全格 = 清图完成——轮末仲裁走 cleared(行动经济 + # notify_cleared 独立上报口,与 request_dig 的 cleared 语义同源;同帧覆盖 ap_exhausted) + action_economy.notify_cleared() if action_economy.phase != action_economy.PHASE_ACTIVE: # 封印动作花完末点 AP 同样终结轮次(行动经济 三出口——AP 用完与动作种类无关;缺漏使 round_end - # 永不发出、战斗结算 的 Waiting 结算点永不到达 = 整局软锁) + # 永不发出、战斗结算 的 Waiting 结算点永不到达 = 整局软锁);清图收轮同此出口发判定批 _emit_round_end_seals_and_payload() return _outcome(true, "executed", "", "") diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index 565bc045..00d7c329 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -172,7 +172,7 @@ func _safe_dig_open(v: Dictionary) -> Dictionary: for c_v in v.resolved: var c := Vector2i(c_v) if fx_host.minesweeper.has_seal(c): - continue + continue # 封印格不代开(封印受理即揭示——天眼揭示绕开承诺格,零动作) if fx_host.minesweeper.fx_reveal(c): opened_any = true if not opened_any: @@ -268,7 +268,7 @@ func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool: return true emit_cb.call({"type": "reward_ineffective", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1)), "highlight": String(att.get("highlight", ""))}, int(att.get("ri", 0)), "奖励未生效(满层)%s" % String(att.get("label_suffix", "")), 0, - {"batch_key": eff}) + {"batch_key": eff, "reason": "full_layers"}) return false @@ -361,12 +361,12 @@ func _flat_heal(row: Dictionary, display: String, att: Dictionary, sync: bool) - # 主角已倒下(同轮更早的负向直伤致死)——治疗不再合法作用,按"奖励未生效"留痕 emit_cb.call({"type": "reward_ineffective", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1)), "highlight": String(att.get("highlight", ""))}, int(att.get("ri", 0)), "%s 未生效(主角已倒下)" % display, 0, - {"batch_key": String(row.reward_id)}) + {"batch_key": String(row.reward_id), "reason": "hero_down"}) return "zero" if int(units.hero_unit().hp) >= int(units.hero_unit().max_hp): emit_cb.call({"type": "reward_ineffective", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1)), "highlight": String(att.get("highlight", ""))}, int(att.get("ri", 0)), "%s 未生效(满血)" % display, 0, - {"batch_key": String(row.reward_id)}) + {"batch_key": String(row.reward_id), "reason": "full_hp"}) return "zero" var healed: int = units.heal(units.hero_unit(), power) # clamp 归 战斗结算 emit_cb.call({"type": "heal", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1)), "highlight": String(att.get("highlight", ""))}, diff --git a/game/core/settlement/combat_segment.gd b/game/core/settlement/combat_segment.gd index 15918f9f..d59ea8ec 100644 --- a/game/core/settlement/combat_segment.gd +++ b/game/core/settlement/combat_segment.gd @@ -121,7 +121,7 @@ func _board_deferred_settle(ri: int) -> void: if int(units.hero_unit().get("hp", 0)) <= 0: for t in (reward_grant.defer["queue"] as Array): emit_row_cb.call({"type": "reward_ineffective", "source_class": String(t.get("source_class", "")), "origin_cell": t.get("origin_cell", Vector2i(-1, -1))}, ri, - "%s 未生效(主角已倒下)" % String(t.get("display", "")), 0, {"forfeit": "hero_down"}) + "%s 未生效(主角已倒下)" % String(t.get("display", "")), 0, {"forfeit": "hero_down", "reason": "hero_down"}) else: for t in (reward_grant.defer["queue"] as Array): damage.submit(t) diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index eb472a60..157de961 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -93,15 +93,12 @@ func _reveal_batch(ri: int) -> void: _reveal_mine(evt, snap, ri) -# safe 事件兑付(封印不可逆四情况④:插安全格再挖开 = 零惩罚零奖励——正向兑付作废〔归因行在案〕; -# 标空判定已随封印擦除自然缺席——被挖的封印不在轮末在场基线。dig_reveal 发射+施加序: +# safe 事件兑付(封印受理即揭示改版:挖封印格已被规则合法性表拒绝——改判路径整体退役, +# 本函数只受理真实揭示的 safe 事件。dig_reveal 发射+施加序: # 乘区收集 → 正向兑付 → 施加面〔计数器/揭示入队/追加产出/奥术矢〕。 # payload 携 is_edge_cell〔边陲矿工〕/is_blank_zero〔跳格采掘〕/ # new_resource_type〔地质学家——快照 cell_type 首见判定,见后集登记〕) func _reveal_safe(evt: Dictionary, snap: Dictionary, ri: int) -> void: - if bool(evt.get("dug_under_seal", false)): - emit_row_cb.call({"type": "reward_ineffective", "source_class": "safe_cell", "origin_cell": evt.cell}, ri, "奖励作废(封印格直接挖开——零惩罚零奖励)", 0, {}) - return var board_b: BoardData = minesweeper.current_board() var ctype := String(snap.get("cell_type", "")) var is_new_type := fx_host.mark_new_resource_type(ctype) @@ -145,12 +142,12 @@ func _reveal_safe_apply(dr_hits: Array, evt: Dictionary, ri: int) -> void: # mine 事件兑付(封印不可逆四情况②:插危险格再挖开 = 只受挖雷一层惩罚——mine_hit 照常兑付惊动巢穴, -# 标中不发〔封印已随挖掘擦除、不入轮末判定基线〕。mine_hit 发射+施加序:施加面收集 → 减伤落快照 → +# mine_hit 发射+施加序:施加面收集 → 减伤落快照 → # 计数器 → 揭示入队 → 负向兑付〔带减半系数〕→ 反打开单) func _reveal_mine(evt: Dictionary, snap: Dictionary, ri: int) -> void: var mine_power := int(snap.get("power", 0)) var mh_hits := fx_host.fx_emit("mine_hit", {"cell": evt.cell, "round": ri, - "damage": mine_power, "dug_under_seal": bool(evt.get("dug_under_seal", false)), + "damage": mine_power, "hp_ratio": int(round(100.0 * float(int(units.hero_unit().get("hp", 0))) / float(maxi(1, int(units.hero_unit().get("max_hp", 1))))))}) var applied := _reveal_mine_apply(mh_hits, snap, evt.cell, ri) if int(applied["mine_reduce"]) > 0: @@ -201,8 +198,8 @@ func _reveal_mine_apply(mh_hits: Array, snap: Dictionary, cell: Vector2i, ri: in # 地形变异 兑付批(授予段尾——触发枚举 on_open:挖开即伤、chain 通道同口径;伤害单据案C 暂存到 # 战斗段末兑付〔案C〕;信息型 hide_number 不进结算——呈现层读取,见 board_view 数字遮蔽; -# 危险格恒无变异——布置段排除,双层叠罚不存在;封印格直挖照吃变异伤害——地形后果同 mine_hit 口径, -# 非奖励不作废;吞噬流资源兑付〔荆棘编织/地脉补偿〕中伤害形态行同样经案C闸暂存,非伤害形态照常当场) +# 危险格恒无变异——布置段排除,双层叠罚不存在;封印格不可挖(封印受理即揭示——变异伤害只随 +# 真实揭示兑付);吞噬流资源兑付〔荆棘编织/地脉补偿〕中伤害形态行同样经案C闸暂存,非伤害形态照常当场) func _terrain_batch(ri: int) -> void: for evt in facts.reveals: var vids: Array[Variant] = evt.get("variant_ids", []) @@ -318,7 +315,7 @@ func _round_end_heal_value_counter(re_fx: Array, ri: int) -> void: continue var hu: Dictionary[Variant, Variant] = units.hero_unit() if int(hu.get("hp", 0)) >= int(hu.get("max_hp", 1)): - emit_row_cb.call({"type": "reward_ineffective", "source_class": "fx_board_refresh", "origin_cell": Vector2i(-1, -1)}, ri, "坑道小憩 未生效(满血)", 0, {"batch_key": String(h.get("value_counter", ""))}) + emit_row_cb.call({"type": "reward_ineffective", "source_class": "fx_board_refresh", "origin_cell": Vector2i(-1, -1)}, ri, "坑道小憩 未生效(满血)", 0, {"batch_key": String(h.get("value_counter", "")), "reason": "full_hp"}) continue var healed: int = units.heal(hu, layers) emit_row_cb.call({"type": "heal", "source_class": "fx_board_refresh", "origin_cell": Vector2i(-1, -1)}, ri, "坑道小憩 回复", healed, {"target": "hero"}) diff --git a/game/data/tables/feats.tres b/game/data/tables/feats.tres index 28687a22..0d749b00 100644 --- a/game/data/tables/feats.tres +++ b/game/data/tables/feats.tres @@ -619,30 +619,6 @@ rows = [ } ] }, - { - "feat_id": "breaker_strike", - "display_name": "破封一击", - "rarity": "excellent", - "cap": 1, - "desc": "挖开带封印的危险格:+1 层(疼痛记忆同账)", - "triggers": [ - { - "on": "mine_hit", - "when": [ - { - "fact": "dug_under_seal", - "op": "eq", - "value": true - } - ], - "do": { - "kind": "counter_add", - "counter_id": "pain_layers", - "value": 1 - } - } - ] - }, { "feat_id": "map_exchange", "display_name": "弃图兑换", diff --git a/game/presentation/hud_presenter.gd b/game/presentation/hud_presenter.gd index 8fa60738..09e7f290 100644 --- a/game/presentation/hud_presenter.gd +++ b/game/presentation/hud_presenter.gd @@ -176,7 +176,8 @@ static func outcome_text(outcome: Dictionary) -> String: "not_adjacent": "不能执行:目标与小人不相邻", "target_revealed": "不能执行:目标已开", "target_concealed": "不能移动:目标未开", - "seal_present": "不能封印:此格已有封印印记(封印为一次性承诺——相邻时左键直接挖开)", + "target_sealed": "不能挖:此格已封印(封印受理即揭示——不可再挖开)", + "seal_present": "不能封印:此格已有封印印记(封印受理即揭示——印记随图存续)", "no_path": "不能移动:已探明区不连通", "out_of_catalog": "无效目标", } diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 3c4887df..cf76ec99 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -249,7 +249,6 @@ func _resume_from_menu() -> void: run_scope.level_active = true sync_status_entry() # 续玩恢复点亮状态面板入口角标 ui.modals.run.level_screen.set_abandon_available(true) - ui.battle.log_view.clear() logger.start_session(int(bp.get("master_seed", 0))) run_scope.adventure.set_hero_override(ui.modals.menu.debug_overlay.atk_pct(), ui.modals.menu.debug_overlay.hp_pct()) # 同调三键随续玩下传(build_adventure_params 统一构造,与开新局同源同口径) @@ -297,7 +296,6 @@ func _start_adventure() -> void: run_scope.level_active = true sync_status_entry() # 进对局点亮状态面板入口角标 ui.modals.run.level_screen.set_abandon_available(true) # 放弃入口挂选路停点(天然非战斗期) - ui.battle.log_view.clear() var seed_val := seed_value() logger.start_session(seed_val) run_scope.adventure.set_hero_override(ui.modals.menu.debug_overlay.atk_pct(), ui.modals.menu.debug_overlay.hp_pct()) @@ -432,13 +430,8 @@ func pace_delay_for(prev_seg: String, next_seg: String) -> float: func _on_round_started(ri: int) -> void: - # 轮首分隔行随排播队列走(落在前轮各行之后——带序正确);轮横幅即轮切换时刻发射 - #(view 内判减动效=静态化变体)。减动效快路=分隔行同帧直落(不跳过——consume_now 复用 - # _consume_row 轮首分隔行分支)。 - if UiKit.motion_scale < 1.0: - ui.screens.row_pacer.consume_now({"__round_line": ri}) - else: - ui.screens.row_pacer.enqueue({"__round_line": ri}) + # 轮横幅即轮切换时刻发射(view 内判减动效=静态化变体);逐行文字列表退役后 + # 轮首分隔行不再入队(B 区明细带承载面已撤)。 ui.battle.settlement_fx.play_round_banner(ri) # 秘宝呈现同步:每战首轮装配链 # 显式重算〔🔴 契约复原语义:下一战装配链重算即含「未装配→写 0」复原〕。 diff --git a/game/presentation/main_ui_build.gd b/game/presentation/main_ui_build.gd index 3ecb916e..b35e21a3 100644 --- a/game/presentation/main_ui_build.gd +++ b/game/presentation/main_ui_build.gd @@ -51,7 +51,6 @@ static func _build_screens(root: Variant) -> void: static func _build_battle_wiring(root: Variant, handlers: Dictionary) -> void: # 结算行排播泵(本体在 row_pacer.gd;本类注入视图句柄与呈现刷新回调,零信号连接) root.ui.screens.row_pacer = RowPacer.new() - root.ui.screens.row_pacer.log_view = root.ui.battle.log_view root.ui.screens.row_pacer.stage = root.ui.battle.stage root.ui.screens.row_pacer.board_zone = root.ui.battle.board_zone root.ui.screens.row_pacer.settlement_fx = root.ui.battle.settlement_fx diff --git a/game/presentation/playtest_logger.gd b/game/presentation/playtest_logger.gd index b8fb64b4..0a7b701f 100644 --- a/game/presentation/playtest_logger.gd +++ b/game/presentation/playtest_logger.gd @@ -129,15 +129,18 @@ func log_level_end(payload: Dictionary) -> void: func _on_step_row(row: Dictionary) -> void: _ring(_recent_steps, {"t": row.type, "src": row.source_class, "v": int(row.value), "r": int(row.round_index)}) - # 结算明细行逐行落盘(五段结算完整回放数据面); + # 结算明细行逐行落盘(五段结算完整回放数据面);零中文文案——text/display_name 两份 + # 玩家文案不落盘(日志零文案裁定 2026-11-07),未生效原因走结构化 reason 键; # 公共七字段之外的全部行字段(payload:roll/ac/tier/crit/normal/pierce/magic/baseline/rider/target/ # effect_id/pool_value 等)随行平铺落盘(无 payload 时反事实只能 # 比例近似,带 payload 可精确重算)。 var line := {"kind": "step", "type": row.type, "source": row.source_class, "cell": row.origin_cell, "round": int(row.round_index), - "text": String(row.display_name), "value": int(row.value), + "value": int(row.value), "highlight": String(row.get("highlight", ""))} for k in row.keys(): + if k == "display_name": + continue # 日志零文案:display_name 平铺跳过(toast 等呈现面消费,日志不落) if not line.has(k): line[k] = row[k] _append(line) diff --git a/game/presentation/row_pacer.gd b/game/presentation/row_pacer.gd index 9791fa8d..9102997b 100644 --- a/game/presentation/row_pacer.gd +++ b/game/presentation/row_pacer.gd @@ -1,20 +1,19 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## 结算行排播泵:逻辑先行零回压,行按段分组阶梯延迟进明细带。 -## 硬契约:只做信号的后置消费——结算顺序/结果/时序逻辑零改动;减动效开=延迟归零同帧直落,行呈现不跳过。 -## 开模态前必须 flush()——明细回看/停点/终局面所见明细带恒完整。 +## 结算行排播泵:逻辑先行零回压,行按段分组阶梯延迟驱动演出链(伤害飘字/弹道/站桩反馈)。 +## 硬契约:只做信号的后置消费——结算顺序/结果/时序逻辑零改动;减动效开=延迟归零同帧直落,演出不跳过。 +## 开模态前必须 flush()——停点/终局面演出链逐行消费恒完整。 ## 视图句柄与 refresh_request/pump_ended 回调由组合根注入——本类零信号连接。 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[Variant] = [] # 待排播行(step_row 载荷原样入队;__round_line 标记=轮首分隔行) +var _rows: Array[Variant] = [] # 待排播行(step_row 载荷原样入队) 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 @@ -79,13 +78,8 @@ func _process(delta: float) -> void: _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 色环脉冲 → 伤害/治疗数字浮现 diff --git a/game/presentation/views/battle_log_view.gd b/game/presentation/views/battle_log_view.gd index 10444258..10d6fd86 100644 --- a/game/presentation/views/battle_log_view.gd +++ b/game/presentation/views/battle_log_view.gd @@ -1,21 +1,16 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## B 区结算明细带——战斗结算步骤流到玩家文案的全部行格式化规则内聚于此(挖掘期收起 1 行上轮摘要、结算期实时流)。 +## B 区结算明细带壳——结算期逐行文字列表退役后保留挖掘期摘要位与冒险流程叙述行承载(append_bb)。 class_name BattleLogView extends PanelContainer -const AGG_TYPES := ["reward_ineffective"] -const COL_GOLD := UiKit.HEX_GOLD - var _db var _band_h_dig := 0 var _band_h_settle := 0 var _log: RichTextLabel var _summary: Label var _hint: Label -var _mode := "dig" # dig = 收起摘要 / settle = 实时流(模式归组合根驱动) -var _round_rows: Array[String] = [] # 本结算轮行缓冲(上轮摘要构建源——append 的是 _format_row 格式化产物 String) -var _pending_agg := {} # 聚合计数行 pending(type+batch_key → {row, count}) +var _mode := "dig" # dig = 收起摘要 / settle = 展开空带(模式归组合根驱动;逐行流退役后 settle 态无逐行内容) func _init(db_ref, layout: BattleLayoutTable) -> void: @@ -64,213 +59,12 @@ func mode() -> String: func clear() -> void: _log.clear() - _round_rows.clear() - _pending_agg = {} _summary.text = "" -# 每结算轮结束(下一轮开始/终局)——刷新挖掘期"上轮摘要"行 -func finish_settlement_round() -> void: - _flush_pending_agg() - if _round_rows.is_empty(): - return - var parts: Array[String] = [] - for bb in _round_rows: - parts.append(_strip_bb(str(bb))) - var text := "上轮: " + " | ".join(parts) - if text.length() > 190: - text = text.substr(0, 190) + "…" - _summary.text = text - _round_rows.clear() - - func append_bb(bb: String) -> void: - _flush_pending_agg() - _log.append_text(bb + "\n") - - -func append_row(row: Dictionary) -> void: - if AGG_TYPES.has(String(row.get("type", ""))): - _accumulate_agg(row) - return - _flush_pending_agg() - var bb := _format_row(row) - _round_rows.append(bb) _log.append_text(bb + "\n") func parsed_text() -> String: return _log.get_parsed_text() - - -# 无点击回看入口——冒险中不给数据回看(归「生涯」06 登记);带保留单行摘要显示 - - -# ---- 聚合计数(未生效类行防逐行惩罚轰炸——pattern 3 聚合纪律) ---- - -func _accumulate_agg(row: Dictionary) -> void: - var key := "%s|%s" % [String(row.get("type", "")), String(row.get("batch_key", ""))] - if _pending_agg.has(key): - _pending_agg[key]["count"] = int(_pending_agg[key]["count"]) + 1 - else: - _flush_pending_agg() - _pending_agg[key] = {"row": row, "count": 1} - - -func _flush_pending_agg() -> void: - if _pending_agg.is_empty(): - return - for key in _pending_agg: - var item: Dictionary[Variant, Variant] = _pending_agg[key] - var row: Dictionary[Variant, Variant] = item["row"] - var count: int = item["count"] - var bb := _format_row(row, count) - _round_rows.append(bb) - _log.append_text(bb + "\n") - _pending_agg = {} - - -# ---- 行格式化(模板「{行为} {值} → {目标}(来源)」) ---- - -func _source_label(src_key: String) -> String: - if src_key == "": - return "" - return String(_db.combat.settlement.core.source_label_map.get(src_key, src_key)) - - -func _gold_frame(text: String) -> String: - # highlight 金框强调(形状+色彩双通道:■ 前缀 + 金底) - return "[bgcolor=%s][color=#%s]■ %s[/color][/bgcolor]" % [COL_GOLD, UiKit.HEX_ON_GOLD, text] - - -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=#%s]R%d%s[/color] " % [UiKit.HEX_LOG_ROUND, round_no, ("·" + src) if src != "" else ""] - var suffix := "(%s)" % src if src != "" else "" - var kind := String(row.get("type", "")) - var body := "" - match kind: - "effect": - body = _fmt_effect(row, prefix, suffix) - "damage": - body = _fmt_damage(row, prefix, suffix) - "heal", "cast", "decay": - body = _fmt_simple(kind, row, prefix, suffix) - "reward_ineffective": - body = _fmt_ineffective(row, prefix, suffix, agg_count) - "seal_miss": - body = _fmt_seal_miss(row, prefix) - "verdict": - body = _fmt_verdict(row) - _: - body = _fmt_default(row, prefix, suffix) - return body - - -## 简单行域(heal/cast/decay 三型——单行单墨直排) -func _fmt_simple(kind: String, row: Dictionary, prefix: String, suffix: String) -> String: - var name_: String = String(row.get("display_name", "")) - match kind: - "heal": - return prefix + "[color=#%s]%s +%d%s%s[/color]" % [UiKit.HEX_LOG_HEAL, name_, int(row.get("value", 0)), _tail_of(row), suffix] - "cast": - # 施放行:增益技 0 伤害施放,具名技能 - return prefix + "[color=#%s]✦ %s%s[/color]" % [UiKit.HEX_LOG_CAST, name_, suffix] - "decay": - return prefix + "[color=#%s]状态衰减 ×%d[/color]" % [UiKit.HEX_LOG_DECAY, int(row.get("value", 0))] - return "" - - -## 默认行域(未知型/高亮星标——未知型=名行直排) -func _fmt_default(row: Dictionary, prefix: String, suffix: String) -> String: - var name_: String = String(row.get("display_name", "")) - if String(row.get("type", "")) == "highlight": - return prefix + _gold_frame("★ %s" % name_) - return prefix + name_ + suffix - - -## 尾注域:目标格指向(行文附加) -func _tail_of(row: Dictionary) -> String: - var target := String(row.get("target", "")) - return " → %s" % target if target != "" else "" - - -## GP 型效果行域:「GP 袋 +3」兑付反馈——value = 本行 ±power, -## escrow = 战斗内缓冲现值(入钱包在战斗末一次上抛——呈现语义与增益池分账) -func _fmt_effect(row: Dictionary, prefix: String, suffix: String) -> String: - var v: int = int(row.get("value", 0)) - var hl := String(row.get("highlight", "")) - var name_: String = String(row.get("display_name", "")) - if String(row.get("effect_id", "")) in ["gp_gain", "gp_loss"]: - 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 := 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]) - - -## 伤害行域:行上归因读数只剩掷点档位随行(护甲等级掷点留档——掷点读数与档位随行可读; -## 吃掷点分量恒带,棋盘来源行不带)。伤害数字不明显——伤害值放大加色独立于行文(朱红大一号) -func _fmt_damage(row: Dictionary, prefix: String, suffix: String) -> String: - var v: int = int(row.get("value", 0)) - var name_: String = String(row.get("display_name", "")) - var tail := _tail_of(row) - var roll_txt := "" - if row.get("roll", null) != null: - roll_txt = "〔掷 %d vs 护甲 %d" % [int(row.roll), int(row.ac)] - if bool(row.get("crit", false)): - roll_txt += " · 暴击×%d" % int(_db.combat.settlement.damage_roll.crit_multiplier) - elif int(row.tier) < 100: - roll_txt += " · ×%d%%" % int(row.tier) - roll_txt += "〕" - var kill := " ⚔击杀!" if row.get("killed", false) else "" - 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] - if bool(row.get("crit", false)): - return prefix + _gold_frame(body) # nat 最大面暴击 = 金框高亮行 - 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) - return prefix + "[color=%s]%s[/color]" % [body_col, body] - - -## 未生效行域:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数 -func _fmt_ineffective(row: Dictionary, prefix: String, suffix: String, agg_count: int) -> String: - var name_: String = String(row.get("display_name", "")) - var n := (" ×%d" % agg_count) if agg_count > 1 else "" - return prefix + "[s][color=#%s]⚠ %s%s[/color][/s]%s" % [UiKit.HEX_LOG_VOIDED, name_, n, suffix] - - -## 封印落空行域:完美破/首失豁免两注记分档(金框=破完美/普通墨=豁免) -func _fmt_seal_miss(row: Dictionary, prefix: String) -> String: - var hl := String(row.get("highlight", "")) - var name_: String = String(row.get("display_name", "")) - var miss_note := "" - if hl == "perfect_broken": - miss_note = "(本图完美封印已破——清图奖励不再加倍)" - elif hl == "": - miss_note = "(首次封印落空已豁免——不计入完美封印判定)" - var body := "✗ %s%s" % [name_, miss_note] - return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#%s]%s[/color]" % [UiKit.HEX_LOG_ROUND, body]) - - -## 裁定行域:胜金/负绿双墨(粗体菱形头) -func _fmt_verdict(row: Dictionary) -> String: - var name_: String = String(row.get("display_name", "")) - if int(row.value) == 1: - return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_WIN, name_] - return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_GAIN, name_] - - -func _strip_bb(bb: String) -> String: - var out := "" - var in_tag := false - for ch in bb: - if ch == "[": - in_tag = true - elif ch == "]": - in_tag = false - elif not in_tag: - out += ch - return out.replace(" ", " ").strip_edges() diff --git a/game/presentation/views/board_hover.gd b/game/presentation/views/board_hover.gd index ceb59525..4dc6eab1 100644 --- a/game/presentation/views/board_hover.gd +++ b/game/presentation/views/board_hover.gd @@ -108,7 +108,7 @@ func _apply_hover_visuals(c: Vector2i) -> void: _show_path_preview(c) elif adjacent: u.face.hl_visible = true - u.face.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖) + u.face.hl_color = COL_HL_TARGET # 挖掘目标格边框(已封印格不入 _diggable——落下方封印禁入分支) _set_cursor_shape(c, CURSOR_DIG) elif seal: u.face.hl_visible = true diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index 394cb56f..9ad8f01c 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -300,8 +300,8 @@ func refresh() -> void: var hero: Vector2i = _minesweeper.hero_pos() _diggable.clear() for n in _board.neighbors8(hero): - if not _minesweeper.is_revealed(n) and not _minesweeper.is_removed(n): - _diggable[n] = true + if not _minesweeper.is_revealed(n) and not _minesweeper.is_removed(n) and not _minesweeper.has_seal(n): + _diggable[n] = true # 已封印格不入可挖域(封印受理即揭示——挖封印格被拒) _recompute_glow(hero) for y in _board.height: for x in _board.width: @@ -474,14 +474,15 @@ func _on_cell_input(c: Vector2i, ev: InputEvent) -> void: if ev.button_index == MOUSE_BUTTON_LEFT: if _minesweeper.is_revealed(c): action_requested.emit(c, "move") + elif _minesweeper.has_seal(c): + pass # 已封印格零动作(封印受理即揭示——挖封印格被拒,左右键同零意图) else: action_requested.emit(c, "dig") _hover_comp.detect_chord(c) elif ev.button_index == MOUSE_BUTTON_RIGHT: if not _minesweeper.has_seal(c): action_requested.emit(c, "seal_place") - # 封印 = 一次性承诺:右键对封印格零动作(无撤封印); - # 左键相邻封印格直接挖(规则引擎放行)。 + # 封印 = 受理即揭示的最终承诺:右键对封印格零动作(无撤封印),左键同零动作。 # ---- 奖励徽章(已开格身份 icon——封印之下、纹带之上、不进数字区) ---- @@ -536,20 +537,22 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: var seal: bool = _minesweeper.has_seal(c) var removed: bool = _minesweeper.is_removed(c) # 中立移除格(退色降档) var is_hero: bool = c == hero - # 封印印记下发(卡④ 法阵化):封印格=未开且未移除 → CellUnit 封印子件绘制(着色器参数化); - # 其余格撤子件。层序与迷雾门控在 CellUnit 侧承载(seal_mark_layer/fog_state 联动)。 - u.seal_on = seal and not revealed + # 封印印记下发(卡④ 法阵化+封印受理即揭示改版):封印格(未移除)→ CellUnit 封印子件绘制 + # (着色器参数化)——印记可见性不依赖揭示态(已揭示格承载=新组合态);其余格撤子件。 + # 层序与迷雾门控在 CellUnit 侧承载(seal_mark_layer/fog_state 联动)。 + u.seal_on = seal if removed: revealed = false # 移除格固定未开呈现基座(退色暗幕层承载;危险格本无数字,周边数字冻结) seal = false u.seal_on = false # 绘制层五段(字形与色牌→基底瓦片→立面投影→秘宝/变异叠加→迷雾)——段间交换经单元状态面 #(u.base/u.face 域件与 u.glyph/u.glyph_color),唯一例外=基底色 bg 经 ink 段返回值交 base 段(无单元场域承载) + var shown: bool = revealed or seal # 呈现揭示态(封印受理即揭示——地板瓦片/身份字形/迷雾开启) var bg: Color = _paint_cell_ink(c, u, revealed, seal) - _paint_cell_base(c, u, revealed, is_hero, bg) - _paint_cell_facade(c, u, revealed) + _paint_cell_base(c, u, shown, is_hero, bg) + _paint_cell_facade(c, u, shown) _paint_cell_overlays(c, u, revealed) - _paint_cell_fog(c, u, revealed, hero) + _paint_cell_fog(c, u, shown, hero) u.face.removed_dim = removed u.queue_redraw() @@ -575,11 +578,19 @@ func _paint_cell_ink(c: Vector2i, u: Node2D, revealed: bool, seal: bool) -> Colo else: glyph = str(d) digit_col = _digit_ink(d) # 数字墨三档色温(§4.1) + elif seal: + # 封印受理即揭示:身份字形按板事实当场可见(cell_identity 单点——不用等轮末判定; + # 数字遮蔽不适用——遮蔽打脸受理即揭示)。 + bg = COL_FLOOR + var ident: Dictionary = _board.cell_identity(c) + if String(ident.get("kind", "")) == "mine": + glyph = "危" + digit_col = UiKit.RED_MINE + elif int(ident.get("digit", 0)) > 0: + glyph = str(int(ident.get("digit", 0))) + digit_col = _digit_ink(int(ident.get("digit", 0))) else: - if seal: - bg = COL_BRICK # 封印印记由 CellUnit 封印子件绘制(卡④ 法阵化——缺图字形回退随贴图路径退役) - else: - bg = COL_BRICK + bg = COL_BRICK u.glyph = glyph u.glyph_color = digit_col # 色牌后备(数字直画退牌;「危」/「?」维持牌载保读性)+ v3.4:双峰纹理板 @@ -588,7 +599,7 @@ func _paint_cell_ink(c: Vector2i, u: Node2D, revealed: bool, seal: bool) -> Colo if plate_digit: digit_col = COL_DIGIT # 牌上深墨族(素牌 12:1——双峰纹理免疫) u.glyph_color = digit_col - u.plate = revealed and (glyph == "危" or glyph == "?" or plate_digit) + u.plate = (revealed or seal) and (glyph == "危" or glyph == "?" or plate_digit) return bg diff --git a/game/presentation/views/board_zone_view.gd b/game/presentation/views/board_zone_view.gd index 52390e21..e1d7f612 100644 --- a/game/presentation/views/board_zone_view.gd +++ b/game/presentation/views/board_zone_view.gd @@ -77,7 +77,7 @@ func _build_board_children(board_area: Control, vb: VBoxContainer) -> void: hint.size_flags_horizontal = Control.SIZE_SHRINK_CENTER hint.mouse_filter = Control.MOUSE_FILTER_IGNORE vb.add_child(hint) - var hl := UiKit.label("左键 挖掘 / 移动 · 右键 封印(不可逆) · 同格双击 快开",UiKit.FS_CAPTION, false, UiKit.WARM_FAINT) + var hl := UiKit.label("左键 挖掘 / 移动 · 右键 封印(受理即揭示·不可逆)",UiKit.FS_CAPTION, false, UiKit.WARM_FAINT) hint.add_child(hl) # 信号转发(组合根只接线一处) board_view.action_requested.connect(_forward_action) diff --git a/game/presentation/views/legend_overlay.gd b/game/presentation/views/legend_overlay.gd index 238d4c0c..dd7f86ff 100644 --- a/game/presentation/views/legend_overlay.gd +++ b/game/presentation/views/legend_overlay.gd @@ -8,7 +8,7 @@ signal closed const ENTRIES := [ ["#C9AF83", "完整覆尘地砖", "未开格——左键挖开(1AP,须相邻)· 右键封印(1AP)"], ["#B39A78", "碎裂裸土", "已开 0 格——可通行,左键移动(免费)"], - ["#000000", "封印", "封印印记(呈现名·不能拔)——轮末自动判定标中;标空可左键直接挖开(安全格零奖励、危险格受挖掘惩罚)"], + ["#000000", "封印", "封印印记(受理即揭示——落定当场可见格下身份,随图存续不能拔)——轮末自动判定标中/标空;已封印格零动作(不可再挖开)"], ["#2e86de", "小人", "探险者——8 向相邻未开格常驻微光 = 可挖域"], ["#F0A828", "金徽章", "已开格奖励身份 icon(封印之下、数值之上,不进数字区)"], ["#F0A828", "九徽章解码", "攻=攻击 · 守=护甲 · 爆=爆裂 · 医=回复 · 弱=削弱 · 穿=穿刺 · 魔=魔法 · 行=行动点 · 金=GP"], diff --git a/game/probe/board_shot_probe.gd b/game/probe/board_shot_probe.gd index 5a8805e3..d976684d 100644 --- a/game/probe/board_shot_probe.gd +++ b/game/probe/board_shot_probe.gd @@ -142,7 +142,7 @@ func _ready() -> void: # 全拒则免邻接旗标+任意未开格重试,AP 不足先 grant_ap 兜底;拒绝全数=取证段红)---- var seal_cell := Vector2i(-9, -9) for c in settlement.minesweeper.current_board().safe_cells(): - if settlement.minesweeper.has_seal(c) and not settlement.minesweeper.is_revealed(c): + if settlement.minesweeper.has_seal(c): # 取证选择器换代〔封印改 V8〕——印记可见性不依赖揭示态(受理即揭示) seal_cell = c break if seal_cell.x < 0: diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index b14e898c..343baa33 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -20,7 +20,6 @@ var _stall_frames := 0 var _closed_via_abandon := false # T17——预算耗尽经放弃路径收口(断点段返回断言豁免位) var _done := false var _rand := RandomNumberGenerator.new() -var _seals_placed: Array = [] var _ap_spent_this_round := 0 var _problems: Array = [] # 新增覆盖的问题收集(退出码合并判定) var _tested_legend := false @@ -37,6 +36,7 @@ var _tested_map_status := false # 地图屏角标一次性 case(选路态 var _inter_confirms := 0 # 大奖励屏确认计数(冒险推进停点——首见确认推进 L2,再见 = L2 关卡完成收口) var _settlement_confirms := 0 # 卡⑥ 胜利结算框确认计数(观测面——每场普通战斗胜利恰一) var _route_clicks := 0 # 选路覆盖层点击计数(岔路使用率观测面) +var _seals_placed: Array = [] # 随机封印簿记(行为①仍在役;行为②撤封印已退役——dump 读数面保留) var _locktest_state := 0 # T1 D1 锁期/重放断言状态机(0=待注入 1=等重放 2=完结)——每 run 一次 var _locktest_cell := Vector2i(-9, -9) var _locktest_frames := 0 @@ -827,7 +827,6 @@ func _ready() -> void: _done = true print("[click_probe] 第二关大奖励屏(L2 关卡完成)——探针收口 · 选路口点击 %d 次" % _route_clicks) break - _seals_placed.clear() continue if main.ui.modals.run.end_overlay.is_open() and main.run_scope.settlement_stop_timer.is_armed(): # 战斗胜利总结结算框(真实信号路径——每场普通胜利到达):断言已拍板面后点「下一节点」 @@ -870,13 +869,14 @@ func _ready() -> void: "phase": main.run_scope.settlement.action_economy.phase, "ap": main.run_scope.settlement.action_economy.ap, "settling": diag.settling, } - # 上轮摘要恢复断言(卡⑥ wr1-b——改前 _round_rows 类型错 append 全数失败、缓冲恒空、 - # 摘要自引入类型错以来从未显示):战中场观测点=round≥2 且排播队列已排空(轮首分隔行 - # 已消费=finish_settlement_round 已刷);不挂循环出口——收尾的续玩重建段会再走 log_view.clear() + # 上轮摘要位退役改判〔退役改 V-退役卡 R1 波及面——test-conventions §5.5 同生死〕: + # 原卡⑥ wr1-b 断言「摘要非空」(功能在役性回归);逐行文字列表退役后摘要内容源 + # (行缓冲)摘除、摘要位恒空——断言反转为退役态防回潮(摘要非空=旧行缓冲复活)。 + # 观测点不变=round≥2 且排播队列已排空;不挂循环出口——收尾的续玩重建段会再走本观测点。 if not _tested_round_summary and int(st["round"]) >= 2 and main.ui.screens.row_pacer.pending_rows().is_empty(): _tested_round_summary = true - if main.ui.battle.log_view._summary.text == "": - _problems.append("上轮摘要恒空(卡⑥ wr1-b——结算行缓冲仍失真)") + if main.ui.battle.log_view._summary.text != "": + _problems.append("上轮摘要非空(逐行流退役后摘要位应恒空——旧行缓冲复活)〔退役改 V-退役卡 R1〕") if JSON.stringify(st) == JSON.stringify(_last_state): # 排播演播期豁免(硬锁 v3——合法停顿态):演播期棋盘锁生效、probe 点击被吞、 # 逻辑态恒定属预期。判据=纯队列(与 main 锁同源)。Waiting 收尾等待期**不豁免**—— # 锁若把收尾链卡死,此处 stall 照报(探测面)。 @@ -1118,9 +1118,13 @@ func _ready() -> void: while ol_l1_wait < 24 and String(main.ui.battle.log_view._log.get_parsed_text()).count("· 种子 ") < 1: ol_l1_wait += 1 await get_tree().process_frame - if ol_l1_wait >= 24: - _problems.append("开场行路径①:L1 开场行未达日志(开局清日志后 24 帧内未现种子行)") - elif String(main.ui.battle.log_view._log.get_parsed_text()).count("(含面板加成)") > 0: + # 判定面换代〔退役改 V-退役卡 R1 波及面〕:逐行文字列表退役后开局清日志动作摘除 + # (main.gd 两处 clear 移除),前段叙述残留不再被清——判定面从「全文本计数」改 + # 「最新开场行本体」(按「· 种子 」形态锚取最后一行,不受残留影响) + var ol_l1_line := _latest_open_line(main) + if ol_l1_wait >= 24 or ol_l1_line == "": + _problems.append("开场行路径①:L1 开场行未达日志(24 帧内未现种子行)") + elif ol_l1_line.count("(含面板加成)") > 0: _problems.append("开场行路径①失守:白板局 L1 开场行带「(含面板加成)」(wc1 反向)") print("[click_probe] 开场行①判定 wait=%d 帧断言完成" % ol_l1_wait) # ②③ 断点续玩正向(既有快照喂入形态——代拍板 2 预授权;生产「继续上局」钮已随三入口化 @@ -1149,9 +1153,10 @@ func _ready() -> void: await get_tree().process_frame if main.run_scope.runner == null or main.run_scope.runner.journal().bonus_total("atk") != 5: _problems.append("开场行路径②③前提失守:续玩重建后加算区 atk!=5(断点回填链断)") - if ol_r_wait >= 24: + var ol_r_line := _latest_open_line(main) # 判定面换代同路径①——最新开场行本体(残留免疫) + if ol_r_wait >= 24 or ol_r_line == "": _problems.append("开场行路径②③失守:续玩重建未发开场行(选路停点形应重发——形态/驱动复核)") - elif String(main.ui.battle.log_view._log.get_parsed_text()).count("(含面板加成)") < 1: + elif ol_r_line.count("(含面板加成)") < 1: _problems.append("开场行路径②③失守:bonus 已入账(pct=0/ov=0)的续玩重建开场行未带「(含面板加成)」(wc1 正向/wc2)") else: print("[click_probe] 开场行②③判定:续玩重建开场行带后缀(bonus.atk=5、pct=0)— wait=%d 帧" % ol_r_wait) @@ -1210,8 +1215,9 @@ func _run_modal_probes_once() -> void: for e in main.ui.modals.run.legend_overlay.ENTRIES: if String(e[1]) == "封印": legend_has_seal = true - # 图例封印行换代断言(迭代36 卡A wa5——真实 AP 条图例钮开层路径) - if not String(e[2]).contains("标空") or not String(e[2]).contains("受挖掘惩罚") or String(e[2]).contains("插错") or String(e[2]).contains("挖雷"): + # 图例封印行换代断言〔封印改 V10〕(迭代36 卡A wa5 载体——真实 AP 条图例钮开层路径): + # 新口径=受理即揭示/已封印格零动作;旧「受挖掘惩罚」句随改判路径退役,雷字清零照守 + if not String(e[2]).contains("受理即揭示") or not String(e[2]).contains("零动作") or String(e[2]).contains("插错") or String(e[2]).contains("挖雷") or String(e[2]).contains("雷格"): _problems.append("图例封印行旧词残留(wa5——%s)" % String(e[2]).replace(" ", "|")) if not legend_has_seal: @@ -1385,12 +1391,8 @@ func _play_one_action() -> void: _seals_placed.append(target) _ap_spent_this_round += 1 return - # 行为②:随机撤封印(右键已封印格),约 10% 轮次 - if _rand.randf() < 0.1 and not _seals_placed.is_empty(): - var fc: Vector2i = _seals_placed.pop_back() - if minesweeper.has_seal(fc) and not minesweeper.is_revealed(fc): - main.ui.battle.board_zone.board_view.click_cell(fc, MOUSE_BUTTON_RIGHT) - _ap_spent_this_round += 1 + # 行为②撤封印退役〔封印改 V9 波及面——test-conventions §5.5 同生死〕:右键已封印格零动作 + # (封印受理即揭示——撤封印路径不存在),随机撤封印行为段整体摘除;_seals_placed 簿记随段退役。 return # 行为③:随机无效点击(非相邻格/已开格),约 8% 轮次 if _rand.randf() < 0.08: @@ -1494,7 +1496,7 @@ func _locktest_tick() -> void: b.width, b.height, str(settlement.minesweeper.hero_pos())]) _problems.append("重放断言:锁期缓存点击未受理恰一次(封印未置位——零发或双发)") _locktest_state = 2 - main.ui.battle.board_zone.board_view.click_cell(_locktest_cell, MOUSE_BUTTON_RIGHT) # 撤封印恢复现场(尽力而为不判定) + # 撤封印恢复现场调用随行为②退役〔封印改 V9〕——右键已封印格零动作(改判路径不存在) func _locktest_pick_cell(minesweeper) -> Vector2i: @@ -1560,6 +1562,16 @@ func _stoptest_assert_hit() -> void: _problems.append("停手定型化段:停手钮真点零命中登记(驱动空转假绿)〔QA收口 V2〕") +# 最新开场行本体(开场行①③判定面换代〔退役改 V-退役卡 R1〕——逐行流退役后开局清日志动作 +# 摘除,判定从「全文本计数」改「最后一行开场行」:按「· 种子 」形态锚自尾向头取首命中行) +func _latest_open_line(main: Node) -> String: + var lines := String(main.ui.battle.log_view._log.get_parsed_text()).split("\n") + for i in range(lines.size() - 1, -1, -1): + if String(lines[i]).count("· 种子 ") >= 1: + return String(lines[i]) + return "" + + func _first_hbox(node: Node) -> HBoxContainer: # 判据出处: design/gdd/systems/表现与支撑/HUD框架.md:90 (位次真点定位——R4 判据面辅助) if node is HBoxContainer: diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index 6cae1d92..c38fc033 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -271,7 +271,7 @@ func _ready() -> void: if bv.mouse_default_cursor_shape != Input.CURSOR_ARROW: _problems.append("case③ 移动光标缺失(%d)" % bv.mouse_default_cursor_shape) # ---- case④ 右键封印:seal_place 到达组合根 + 核心封印态落位 ---- - # 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:231 封印受理与印记随挖消失(case④/④b) + # 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:52 封印受理即揭示、受理零兑付零事件(case④/④b——受理即揭示改版换代) _push_click(_pos(bv, seal_t), MOUSE_BUTTON_RIGHT) await get_tree().process_frame if not _has_action(seal_t, "seal_place"): @@ -284,6 +284,32 @@ func _ready() -> void: var seal_unit = bv.cells_probe().get(seal_t) if seal_unit == null or not seal_unit.seal_on: _problems.append("case④b 封印印记未下发(seal_on=%s)" % ["无单元" if seal_unit == null else str(seal_unit.seal_on)]) + # ---- case④c(V9 新增)已封印格零动作:真输入左/右键 → action_requested 零发射、光标态=禁止 ---- + # 判据出处: design/ux/battle.md Interaction Map 已封印格行——已封印格无动作(左右键同零意图) + var acts_before_v9: int = _actions.size() + _push_motion(_pos(bv, seal_t)) + await get_tree().process_frame + if bv.mouse_default_cursor_shape != Input.CURSOR_FORBIDDEN: + _problems.append("case④c 悬停已封印格光标非禁止(%d)〔封印改 V9〕" % bv.mouse_default_cursor_shape) + if bv.cells_probe()[seal_t].face.hl_color != bv.COL_SEAL: + _problems.append("case④c 已封印格悬停未落封印禁入框(color=%s)〔封印改 V9〕" % str(bv.cells_probe()[seal_t].face.hl_color)) + _push_click(_pos(bv, seal_t), MOUSE_BUTTON_LEFT) + await get_tree().process_frame + _push_click(_pos(bv, seal_t), MOUSE_BUTTON_RIGHT) + await get_tree().process_frame + if _actions.size() != acts_before_v9: + _problems.append("case④c 已封印格左右键仍发射意图(新增 %d——应零发射)〔封印改 V9〕" % (_actions.size() - acts_before_v9)) + if not ms.is_revealed(seal_t): + pass # 封印格保持未开(零副作用同源佐证) + else: + _problems.append("case④c 已封印格被挖开(零副作用失守)〔封印改 V9〕") + # 悬停说明在册:图例封印行承载悬停语义说明(受理即揭示口径——ENTRIES 在册断言) + var legend_seal_line := "" + for e in main.ui.modals.run.legend_overlay.ENTRIES: + if String(e[1]) == "封印": + legend_seal_line = String(e[2]) + if not legend_seal_line.contains("受理即揭示"): + _problems.append("case④c 悬停说明在册缺新口径(图例封印行=%s)〔封印改 V9〕" % legend_seal_line) # ---- case⑤ 双击 chord:禁入格双左击(核心拒挖零副作用)→ chord_attempted ---- # 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:236 拒绝三源可辨、任何拒绝零副作用(case⑤ chord) _push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT) diff --git a/game/sim/auto_player.gd b/game/sim/auto_player.gd index c840b608..6ed6ba46 100644 --- a/game/sim/auto_player.gd +++ b/game/sim/auto_player.gd @@ -174,7 +174,10 @@ static func _constraints(minesweeper, board, mines: Dictionary) -> Array: var known := 0 for n in board.neighbors8(c): if minesweeper.has_seal(n): - known += 1 + # 封印受理即揭示:封印格身份当场可查(测量侧镜像玩家可读信息——cell_identity 板事实单点)。 + # 标中(雷)计已知雷;标空(安全格)=已完成格,剔除出约束(旧口径一律计已知雷=失真)。 + if String(board.cell_identity(n).get("kind", "")) == "mine": + known += 1 elif not minesweeper.is_revealed(n): if mines.has(n): known += 1 diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index ec06dab9..bfba1ce7 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -25,7 +25,8 @@ const BattleDriver := preload("res://sim/battle_driver.gd") # 庚案收敛: # ── 普通档 v3 失误注入参数(踩雷期望=完美+2/板、每板少挖几个正确格)── # 标雷→误挖转化率:主踩雷源=卡死赌挖(不确定性概率命中),误挖只作偶发真手滑—— # 真人几乎不挖自己封印的格(p 过高会让首轮已证封印变保底命中,非失误形态) -const NORMAL_MINE_SLIP_P := 0.10 +const NORMAL_MISDIG_P := 0.10 # 错触直挖率(补偿注入——用户 2026-11-07 裁③:初值承旧注入 0.10, +# 标定目标=改前锚普通档踩雷读数量级;同批同码态锚只采一次,偏差记实现决定) # 安全挖跳过率:每板安全格 ~70-90 × p ≈ 3-5 次漏挖(信息与推进双损失) const NORMAL_SKIP_DIG_P := 0.05 # 空闲提前赌挖率:已证安全格在手时小概率先赌一格——完美系把不确定性攒到残局 @@ -471,7 +472,7 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void # 普通策略档 v3:完美策略 + 受控失误注入(标定依据=真人实证: # 零加成真人首通三关踩 23 雷)。 # v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/封印/卡死刷新),失误只注入两手—— - # 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。 + # 错触直挖(NORMAL_MISDIG_P——标雷误挖换代名)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。 # 完美策略档:单步决策循环——move/dig/seal 经 排雷玩法 settle_batch 提交, # refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板), # stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。 @@ -503,9 +504,11 @@ static func _action_logger(st: Dictionary, logger) -> Callable: st.refreshes = int(st.refreshes) + 1 elif ok and kind == "seal_place": st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔Q5〕;失误注入转化封印不计数=转化后实为挖) -# 普通档 v3 失误注入:三类—— -# ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错, -# 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × NORMAL_MINE_SLIP_P(目标完美+2/板) +# 普通档 v3 失误注入:三类(封印受理即揭示改版换代——①标雷误挖退役,等价补偿注入=错触直挖, +# 用户 2026-11-07 裁③)—— +# ①错触直挖:把「封印已证雷」动作按概率转成「挖该格」(真人错触原型:判雷正确、手指落点成挖; +# 目标格当时未封未揭——新规则下挖未封格合法可执行,踩雷吃伤吃挖掘惩罚);期望踩雷增量 ≈ +# 每板已证雷数 × NORMAL_MISDIG_P(初值承旧注入 0.10——标定目标=改前锚普通档踩雷读数量级) # ②安全挖跳过:把「挖已证安全格」动作转成本轮停手(少挖几个正确的雷——信息与推进双损失) # ③卡死赌挖:完美档无已证动作时刷新/停手拖轮次吃敌方垫刀(实证=完美档 41% 冒险成功;全败于慢玩 # 垫刀非踩雷)——真人会按概率赌一个边界格(用户实证口径「有些地方因为我看不到数字,也是盲挖 @@ -516,8 +519,8 @@ static func _apply_normal_slips(act: Dictionary, settlement, st: Dictionary) -> if prng == null: return act var kind := String(act.get("kind", "stop")) - if kind == "seal" and prng.randf() < NORMAL_MINE_SLIP_P: - st.mine_slips = int(st.get("mine_slips", 0)) + 1 + if kind == "seal" and prng.randf() < NORMAL_MISDIG_P: + st.mine_slips = int(st.get("mine_slips", 0)) + 1 # 计数字段沿用 mine_slips(锚对比列对齐) return {"kind": "dig", "cell": act.get("cell")} if kind == "dig": var roll := prng.randf() diff --git a/game/tests/golden/windows/board_L1_dungeon.png b/game/tests/golden/windows/board_L1_dungeon.png index e5a587ef..a4764d43 100644 Binary files a/game/tests/golden/windows/board_L1_dungeon.png and b/game/tests/golden/windows/board_L1_dungeon.png differ diff --git a/game/tests/golden/windows/board_L2_underdark.png b/game/tests/golden/windows/board_L2_underdark.png index 2e5b892a..4fdf2454 100644 Binary files a/game/tests/golden/windows/board_L2_underdark.png and b/game/tests/golden/windows/board_L2_underdark.png differ diff --git a/game/tests/golden/windows/board_L3_deep_underdark.png b/game/tests/golden/windows/board_L3_deep_underdark.png index 7380c119..76d6a2b4 100644 Binary files a/game/tests/golden/windows/board_L3_deep_underdark.png and b/game/tests/golden/windows/board_L3_deep_underdark.png differ diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index ae7a4287..1287b52f 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -192,6 +192,8 @@ 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/battle_log_zero_text_test.gd", "expect": 0}, # 结算期 B 区零逐行文字(逐行结算排播退役——FX 排播链保持〔退役改 V-退役卡 R1〕) + {"path": "res://tests/unit/presentation/playtest_logger_step_fields_test.gd", "expect": 0}, # 日志 step 行零文案与全字段落盘〔退役改 V-退役卡 R3〕 {"path": "res://tests/unit/presentation/settings_store_test.gd", "expect": 0}, # 减动效开关存储契约(持久化回读/缺省值/引擎动效档应用)〔断言补齐批〕 {"path": "res://tests/unit/battle_settlement/boss_gate_test.gd", "expect": 0}, # Boss 节点开战必选 Boss 行集(行类路由+端到端)〔断言补齐批〕 {"path": "res://tests/unit/data_config/effect_kind_retired_test.gd", "expect": 1, "negative": true}, # 甲案词表退役——overflow/attack_segments 误写加载致命〔架构整改〕 @@ -211,6 +213,7 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/palette_token_pairwise_test.gd", "expect": 0}, # token 板两两互查守门(色板增量纪律存量面——豁免账外零近色对,六对欠账在册) {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, + {"path": "res://tests/unit/battle_settlement/reward_ineffective_reason_test.gd", "expect": 0}, # 奖励未生效 reason 四类结构化〔退役改 V-退役卡 R5〕 {"path": "res://tests/unit/battle_settlement/effect_port_test.gd", "expect": 0}, # 效果端口件(效果端口与掷格分件批 V1–V3——端口单实例/业务类零引擎直连源码锚/事实面闭集两对照) {"path": "res://tests/unit/battle_settlement/roll_multiplier_test.gd", "expect": 0}, # 掷点与乘区宿主(敌攻击类型轴/护甲掷点行为面/乘区接线/伤害类型克制与标错/fresh 池攻击链——批 B 自 smoke_all 迁入) {"path": "res://tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd", "expect": 0}, # 结算接线同种子逐项 golden 对拍主证〔封装5.1〕(架构审查 5.1 封装修复——段类束注入改造零行为变更基线) diff --git a/game/tests/smoke/critical-paths.md b/game/tests/smoke/critical-paths.md index b9c919fd..f9659da3 100644 --- a/game/tests/smoke/critical-paths.md +++ b/game/tests/smoke/critical-paths.md @@ -13,10 +13,10 @@ ## Core Mechanic 3. 挖掘:单格 1 AP、数字亮起与 8 邻雷数一致、连锁免费撕开一片 -4. 封印/撤封印/挪封印:AP 价目正确、连锁止于封印(封印挡链)属正常 +4. 封印:AP 价目正确、连锁止于封印(封印挡链)属正常;封印受理即揭示(落定当场见格下身份)、已封印格零动作(不可再挖开) 5. 移动:已开格瞬移、**已引爆危险格可通行可读**(工程债呈现半边) 6. 停手:RAP 1:1 转存、下轮 AP 硬重置同图继续 -7. 轮末结算:明细逐行呈现、来源标注可读(P2——"这一行是我哪步扫雷带来的"可答) +7. 轮末结算:FX 链逐拍兑现(弹道/飘字/站桩反馈)可读(P2——"这一战果是我哪步扫雷带来的"可答;明细带逐行文字已退役) 8. 清图:清图奖励兑付 + 完美封印高光(零误标时双份)+ 换新图;未刷新的停手轮不换图 9. 轮内刷新(方案 A):Active 当场换图、AP 保留、RAP 扣减、每轮 ≤2 次、跨图事实结算完整 diff --git a/game/tests/unit/battle_settlement/reward_ineffective_reason_test.gd b/game/tests/unit/battle_settlement/reward_ineffective_reason_test.gd new file mode 100644 index 00000000..74385f89 --- /dev/null +++ b/game/tests/unit/battle_settlement/reward_ineffective_reason_test.gd @@ -0,0 +1,138 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 奖励未生效行 reason 结构化断言(结算期文字列表退役批 T2——日志零文案后未生效原因的分析判读面): +# 四类合法例外(满层拒收 / 满血治疗 / 无存活敌人 / 主角已倒下)的真实结算行各带结构化 reason 键, +# 取值逐一命中 full_layers / full_hp / no_enemy_alive / hero_down(分析工具按此四值分类—— +# 旧 text 子串判读退役后的替代判据面)。 +# 真实入口:真实板格身份快照(query_full_snapshot)→ grant_routed 正向路由 → 轮末结账; +# 两处受控例外直改(对照表申报:无公开 setter 可钉)——hero hp=0(倒下因)与敌 hp=1(先手清场因)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/reward_ineffective_reason_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") +const RewardTable := preload("res://data/schemas/reward_table.gd") + +var _db + + +func _init() -> void: +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:80 奖励未生效行=满层拒收、满血治疗等合法例外一行 +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:87 显示名等玩家可见文案是数据面字段(日志消费端零文案——reason 结构化替代) + _db = ConfigDBScript.new() + if not _db.load_tables("res://data/tables", true): + print("[reward_ineffective_reason_test] FAIL — 数值表加载失败") + quit(1) + return + _assert_four_reasons() + quit(Kit.finish("reward_ineffective_reason_test")) + + +func _orch(seed_v: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_v) + return load("res://core/battle_orchestrator.gd").new(_db, rng) + + +# 真实板扫描:取某 cell_type 的首个非封锁格身份快照(seal_polarity 负向直验同款形态) +func _snap_of_type(settlement, ctype: String): + var board = settlement.minesweeper.current_board() + var r0 := {} + for c in board.compute_r0(): + r0[c] = true + for y in board.height: + for x in board.width: + var c := Vector2i(x, y) + if r0.has(c): + continue + var ident = settlement.query_reward_identity(c) + if ident != null and String(ident.cell_type) == ctype \ + and String(ident.cell_type) != RewardTable.TREASURE_ITEM_TYPE: + return settlement._reward_economy.query_full_snapshot(c) + return null + + +func _ineffective_rows(rows: Array) -> Array: + var out: Array = [] + for r in rows: + if String(r.get("type", "")) == "reward_ineffective": + out.append(r) + return out + + +# 四类 reason 逐一命中(断言前提=各类未生效行在案;reason 键取值=分析判读契约四值) +func _assert_four_reasons() -> void: +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:80 奖励未生效行四类合法例外 + print("--- 奖励未生效 reason 四类逐一命中 ---") + + # ---- ① full_hp:满血主角 + heal 型格正向(_flat_heal 满血分支即时兑付)---- + var s1 = _orch(20261108) + var rows1: Array = [] + s1.step_row.connect(func(row): rows1.append(row.duplicate(true))) + Kit.check("① 前提:战斗受理", s1.begin_battle({"level_index": 1, "node_depth": 0, + "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)})) + var heal_snap = _snap_of_type(s1, "heal") + Kit.check("① 前提:heal 型格在板", heal_snap != null) + if heal_snap != null: + s1.reward_grant().grant_routed(heal_snap, "positive", {"source_class": "safe_cell", "cell": Vector2i(0, 0), "ri": 1}) + var hits := 0 + for r in _ineffective_rows(rows1): + if String(r.get("reason", "")) == "full_hp": + hits += 1 + Kit.check("① 满血治疗行带 reason=full_hp(命中 %d 行)" % hits, hits >= 1) + + # ---- ② hero_down:主角 hp=0(受控例外直改——无公开 setter)+ heal 正向 → 倒下分支 ---- + var s2 = _orch(20261109) + var rows2: Array = [] + s2.step_row.connect(func(row): rows2.append(row.duplicate(true))) + Kit.check("② 前提:战斗受理", s2.begin_battle({"level_index": 1, "node_depth": 0, + "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)})) + var heal_snap2 = _snap_of_type(s2, "heal") + Kit.check("② 前提:heal 型格在板", heal_snap2 != null) + if heal_snap2 != null: + var hu = s2._enemy_system.hero_unit() # 受控例外:hero hp 直改(倒下因无公开写口) + hu["hp"] = 0 + s2.reward_grant().grant_routed(heal_snap2, "positive", {"source_class": "safe_cell", "cell": Vector2i(0, 0), "ri": 1}) + var hits2 := 0 + for r in _ineffective_rows(rows2): + if String(r.get("reason", "")) == "hero_down": + hits2 += 1 + Kit.check("② 主角已倒下行带 reason=hero_down(命中 %d 行)" % hits2, hits2 >= 1) + + # ---- ③ full_layers:defense 型格负向 → off_guard(stack 策略 max 16)反复授予至满层 → 全体拒收行 + # (满层拒收只发生在 stack 策略——accumulate 恒加层无拒收面;负向 mine_hit 路由=真实入口)---- + var s3 = _orch(20261110) + var rows3: Array = [] + s3.step_row.connect(func(row): rows3.append(row.duplicate(true))) + Kit.check("③ 前提:战斗受理", s3.begin_battle({"level_index": 1, "node_depth": 0, + "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)})) + var def_snap = _snap_of_type(s3, "defense") + Kit.check("③ 前提:defense 型格在板", def_snap != null) + if def_snap != null: + var max_stack: int = int(_db.combat.status_effects.row_by_id("off_guard").get("max_stack", 16)) + for i in range(max_stack + 3): # 堆满后再给 3 份——拒收段必然到达 + s3.reward_grant().grant_routed(def_snap, "negative", {"source_class": "mine_hit", "cell": Vector2i(0, 0), "ri": 1}) + var hits3 := 0 + for r in _ineffective_rows(rows3): + if String(r.get("reason", "")) == "full_layers": + hits3 += 1 + Kit.check("③ 满层拒收行带 reason=full_layers(off_guard max_stack=%d,命中 %d 行)" % [max_stack, hits3], hits3 >= 1) + + # ---- ④ no_enemy_alive:敌 hp=1(受控例外直改)+ blast 正向单两张 defer → 轮末结账首单清场、次单无敌 ---- + var s4 = _orch(20261111) + var rows4: Array = [] + s4.step_row.connect(func(row): rows4.append(row.duplicate(true))) + Kit.check("④ 前提:战斗受理", s4.begin_battle({"level_index": 1, "node_depth": 0, + "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)})) + var blast_snap = _snap_of_type(s4, "blast") + Kit.check("④ 前提:blast 型格在板", blast_snap != null) + if blast_snap != null: + for u in s4._enemy_system.query_alive("enemy"): # 受控例外:敌 hp 直改(先手清场因) + u["hp"] = 1 + s4.reward_grant().grant_routed(blast_snap, "positive", {"source_class": "safe_cell", "cell": Vector2i(0, 0), "ri": 1}) + s4.reward_grant().grant_routed(blast_snap, "positive", {"source_class": "safe_cell", "cell": Vector2i(0, 0), "ri": 1}) # 次单=结账时已无敌 + s4.minesweeper.settle_batch([{"action": "stop"}]) # 轮末五段——战斗段末 defer 统一结账 + var hits4 := 0 + for r in _ineffective_rows(rows4): + if String(r.get("reason", "")) == "no_enemy_alive": + hits4 += 1 + Kit.check("④ 无存活敌人行带 reason=no_enemy_alive(命中 %d 行)" % hits4, hits4 >= 1) diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd index 7a5a171d..539b7562 100644 --- a/game/tests/unit/battle_settlement/seal_polarity_test.gd +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -48,6 +48,7 @@ func _init() -> void: # ---- 末点 AP 封印轮末结算(playtest 缺陷②回归:末点 AP 花在封印必须终结轮次进入下一轮)---- func _smoke_seal_ap_exhausted() -> void: +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:139 封印判定批与溢价兑付链照常(保留组——封印受理即揭示改版零改动)〔封印改 V5〕 # 判据出处: design/gdd/systems/排雷循环/行动经济.md:260 (末点 AP 耗尽即收轮组) print("--- 排雷玩法·封印动作耗尽末点 AP 回归 ---") var rng = load("res://core/rng_service.gd").new() @@ -111,11 +112,11 @@ func _find_seal_target(minesweeper, want_mine: bool) -> Dictionary: # ---- 封印不可逆四情况冒烟(禁撤封印 + 挖封印格兑付改判)---- -# 四情况:① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发; -# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。 +# 四情况(封印受理即揭示改版改判〔封印改 V2〕):① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 挖封印格被拒(零动作零惩罚); +# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 挖封印格被拒(零动作——标空判定照发)。 # 拒绝面:seal_pull 出目录(out_of_catalog);重复封印仍 seal_present 拒绝。 -# 场景 A(④ + 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 直接挖开 → 收轮。 -# 场景 B(②):标危险格 → 直接挖开 → 收轮。 +# 场景 A(④ 改判〔封印改 V2〕+ 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 挖封印格被拒 → 收轮(标空判定照发)。 +# 场景 B(② 改判〔封印改 V2〕):标危险格 → 挖封印格被拒 → 收轮(标中判定照发)。 # 场景 C(①③):标安全格 + 标危险格 → 不挖收轮 → 标空/标中判定照常。 @@ -440,6 +441,7 @@ func _smoke_refresh_provenance_guard() -> void: func _smoke_polarity_routing() -> void: +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:139 封印判定批与溢价兑付链照常(保留组——封印受理即揭示改版零改动)〔封印改 V5〕 # 判据出处: design/gdd/systems/战斗循环/战斗结算.md:366 (极性路由:正向只兑正向、负向只兑负向组) # 判据出处: design/gdd/systems/战斗循环/战斗结算.md:366 (极性路由:正向只兑正向、负向只兑负向组) print("--- 极性路由(挖安全格正向 / 挖危险格负向 / 标中正向 / 标空负向)---") @@ -572,11 +574,11 @@ func _smoke_polarity_routing() -> void: # ---- 封印不可逆四情况冒烟(禁撤封印 + 挖封印格兑付改判)---- -# 四情况:① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发; -# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。 +# 四情况(封印受理即揭示改版改判〔封印改 V2〕):① 插危险格不动 = 标中奖励照常;② 插危险格再挖开 = 挖封印格被拒(零动作零惩罚); +# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 挖封印格被拒(零动作——标空判定照发)。 # 拒绝面:seal_pull 出目录(out_of_catalog);重复封印仍 seal_present 拒绝。 -# 场景 A(④ + 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 直接挖开 → 收轮。 -# 场景 B(②):标危险格 → 直接挖开 → 收轮。 +# 场景 A(④ 改判〔封印改 V2〕+ 拒绝面):标安全格 → seal_pull 拒绝 → 重复封印拒绝 → 挖封印格被拒 → 收轮(标空判定照发)。 +# 场景 B(② 改判〔封印改 V2〕):标危险格 → 挖封印格被拒 → 收轮(标中判定照发)。 # 场景 C(①③):标安全格 + 标危险格 → 不挖收轮 → 标空/标中判定照常。 func _smoke_seal_irreversible_quadrants() -> void: # 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:231 (封印不可逆四情况组) @@ -600,9 +602,10 @@ func _smoke_seal_irreversible_quadrants() -> void: Kit.check("四情况 A:seal_pull 出目录拒绝(撤封印退役)", not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog") var dup_place: Array = ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}]) Kit.check("四情况 A:重复封印仍被拒(seal_present)", not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "seal_present") + # 改判〔封印改 V2〕——「挖封印格直挖受理/隐式退役」随封印受理即揭示退役:挖已封印格被拒 var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.seal_cell}]) - Kit.check("四情况 A:封印格直接挖开被受理(不再须先撤封印)", not dig_a.is_empty() and bool(dig_a[0].get("accepted", false))) - Kit.check("四情况 A:封印随挖掘隐式退役(has_seal = false)", not ms_a.has_seal(t_a.seal_cell)) + Kit.check("四情况 A:挖已封印格被拒且原因可辨(target_sealed)", not dig_a.is_empty() and not bool(dig_a[0].get("accepted", true)) and String(dig_a[0].get("sub_reason", "")) == "target_sealed") + Kit.check("四情况 A:封印不随挖掘退役(has_seal 保持——不可再挖开)", ms_a.has_seal(t_a.seal_cell) and not ms_a.is_revealed(t_a.seal_cell)) ms_a.settle_batch([{"action": "stop"}]) var target_a: Array = [t_a.seal_cell.x, t_a.seal_cell.y] var miss_a := 0 @@ -617,9 +620,11 @@ func _smoke_seal_irreversible_quadrants() -> void: grant_a += 1 if row.get("type", "") == "reward_ineffective": void_a += 1 - Kit.check("四情况 A:标空判定零兑付(×%d——封印已擦除不入轮末基线)" % miss_a, miss_a == 0) - Kit.check("四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a, grant_a == 0) - Kit.check("四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a, void_a == 1) + # 改判〔封印改 V2〕——封印在场入轮末基线:标空判定照发(受理即揭示不改判定批); + # miss_a 口径=seal_miss 型行或来源行(判定行+标空负向兑付行同源) + Kit.check("四情况 A:标空判定照发(×%d——封印在轮末基线;判定行+负向兑付行同源)" % miss_a, miss_a >= 1) + Kit.check("四情况 A:安全格正向兑付为零(×%d——封印不兑付格面资源)" % grant_a, grant_a == 0) + Kit.check("四情况 A:零兑付作废归因行(×%d——改判路径退役无作废行)" % void_a, void_a == 0) # 场景 B:危险格直挖(情况②) var rng_b = load("res://core/rng_service.gd").new() rng_b.set_master_seed(20260919) @@ -634,9 +639,10 @@ func _smoke_seal_irreversible_quadrants() -> void: if t_b.hero_cell != ms_b.hero_pos(): ms_b.settle_batch([{"action": "move", "cell": t_b.hero_cell}]) Kit.check("四情况 B:封印受理", ms_b.settle_batch([{"action": "seal_place", "cell": t_b.seal_cell}])[0].accepted) + # 改判〔封印改 V2〕——挖危险封印格同被拒(封印格拒绝挖掘不分极性) var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.seal_cell}]) - Kit.check("四情况 B:封印危险格直接挖开被受理", not dig_b.is_empty() and bool(dig_b[0].get("accepted", false))) - Kit.check("四情况 B:危险格已揭示(挖雷本体照常)", ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell)) + Kit.check("四情况 B:挖已封印危险格被拒(target_sealed)", not dig_b.is_empty() and not bool(dig_b[0].get("accepted", true)) and String(dig_b[0].get("sub_reason", "")) == "target_sealed") + Kit.check("四情况 B:危险格未揭示(封印格零动作)", not ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell)) ms_b.settle_batch([{"action": "stop"}]) var target_b: Array = [t_b.seal_cell.x, t_b.seal_cell.y] var mine_hit_b := 0 @@ -648,8 +654,9 @@ func _smoke_seal_irreversible_quadrants() -> void: mine_hit_b += 1 if row.get("type", "") == "seal_hit" or String(row.get("source_class", "")) == "seal_hit": hit_b += 1 - Kit.check("四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b, mine_hit_b >= 1) - Kit.check("四情况 B:标中不发(×%d——封印已擦除不判)" % hit_b, hit_b == 0) + # 改判〔封印改 V2〕——封印在场入轮末基线:标中判定照发(含溢价兑付链) + Kit.check("四情况 B:挖雷惩罚零发生(×%d——封印格未挖)" % mine_hit_b, mine_hit_b == 0) + Kit.check("四情况 B:标中判定照发(×%d——封印在轮末基线)" % hit_b, hit_b >= 1) # 场景 C:不挖收轮(①③判定照常) var rng_c = load("res://core/rng_service.gd").new() rng_c.set_master_seed(20260919) diff --git a/game/tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd b/game/tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd index b9d81c91..e75704bc 100644 --- a/game/tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd +++ b/game/tests/unit/battle_settlement/settlement_wiring_snapshot_test.gd @@ -420,6 +420,8 @@ var _db func _init() -> void: # 判据出处: design/gdd/systems/战斗循环/战斗结算.md:362 (结算接线同种子逐项 golden 对拍组) +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:134 受理零兑付、改判路径死——流水零「奖励作废(封印格直接挖开)」行、 +# 同种子驱动序 golden 实测零漂移零重录(挖封印格被拒——驱动序不含该路径)〔封印改 V6〕 _db = ConfigDBScript.new() var load_ok: bool = _db.load_tables("res://data/tables", true) Kit.check("前置:表加载零 error", load_ok and _db.diagnostics_count("error") == 0) diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index bdc02871..2e8bafba 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -147,9 +147,11 @@ func _smoke_adventure() -> void: Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss], cov_scan > 0 and cov_miss == 0) # ② 满配冒险成功 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) - var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) + # 种子换代〔退役改 V7 波及面——test-conventions §5.5 同生死〕:feats.tres 删破封一击行使 + # RNG 流消费序列变,42 路线漂移为 timeout(43 实跑 completed——钉值随表行集换代) + var st: Dictionary = autoplay.run_adventure(_db, 43, 900, 400) var sum: Dictionary = st["summary"] - Kit.check("满配冒险成功(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false))) + Kit.check("满配冒险成功(43 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false))) Kit.check("完成几关 = 3(levels_cleared)", int(sum.get("levels_cleared", -1)) == 3) var ks_exp := 0 for li in range(1, int(_db.run.level.rows.size()) + 1): @@ -157,7 +159,7 @@ func _smoke_adventure() -> void: Kit.check("钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)), int(sum.get("keystone_total", -1)) == ks_exp) var drops: Array = sum.get("keystone_drops", []) as Array Kit.check("逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)", drops.size() == 3) - Kit.check("摘要携带主种子(42)", int(sum.get("master_seed", -1)) == 42) + Kit.check("摘要携带主种子(43)", int(sum.get("master_seed", -1)) == 43) var picks: Array = [] var resolves: Array = [] var node_picks := 0 @@ -274,7 +276,7 @@ func _smoke_adventure() -> void: seq_ok = false Kit.check("各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base, seq_ok) # ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致) - var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) + var st2: Dictionary = autoplay.run_adventure(_db, 43, 900, 400) Kit.check("同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)", st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"]) @@ -465,8 +467,8 @@ func _smoke_adventure() -> void: func _smoke_feats() -> void: # 判据出处: design/gdd/systems/冒险循环/奇物.md:465 (奇物授予与凭证保底组) print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") - # ① 现行表牌库构成:37 件全量入池——102 数值令牌+37 relic 令牌(四级 rarity 直映射) - Kit.check("feat_table 已加载(37 件=基础 27+迁入 2+完美保险 1+迭代 46 换图系七件——换代)", _db.crossrun.feats != null and (_db.crossrun.feats.rows as Array).size() == 37) + # ① 现行表牌库构成:36 件全量入池(破封一击随封印受理即揭示退役〔退役改 V7〕)——102 数值令牌+36 relic 令牌(四级 rarity 直映射) + Kit.check("feat_table 已加载(36 件=基础 27+迁入 2+完美保险 1+迭代 46 换图系七件−破封一击退役〔退役改 V7〕——换代)", _db.crossrun.feats != null and (_db.crossrun.feats.rows as Array).size() == 36) Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.crossrun.feats.validate().is_empty()) var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71)) @@ -477,7 +479,7 @@ func _smoke_feats() -> void: feat_tokens += 1 var tk := String((t as Dictionary).get("tier", "")) tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 - Kit.check("专长带入池:37 件四级直映射(普通 14/优秀 13/稀有 10/传说 0)——牌库 102+37=139〔专长批 V2+迭代 46 换图系七件换代〕", feat_tokens == 37 and int(tier_counts.get("common", 0)) == 14 and int(tier_counts.get("excellent", 0)) == 13 and int(tier_counts.get("rare", 0)) == 10 and a_r0.deck().deck.size() == 139) + Kit.check("专长带入池:36 件四级直映射(普通 14/优秀 12/稀有 10/传说 0)——牌库 102+36=138〔专长批 V2+迭代 46 换图系七件换代−破封一击退役〕", feat_tokens == 36 and int(tier_counts.get("common", 0)) == 14 and int(tier_counts.get("excellent", 0)) == 12 and int(tier_counts.get("rare", 0)) == 10 and a_r0.deck().deck.size() == 138) # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.crossrun.feats.row_by_id("arcane_ember").is_empty() and _db.crossrun.feats.row_by_id("magma_body").is_empty()) Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.crossrun.treasure_items.row_by_id("venom_edge").is_empty() and _db.crossrun.treasure_items.row_by_id("seal_burst").is_empty()) @@ -717,11 +719,7 @@ func _smoke_feats() -> void: Kit.check("背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1 and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty()) - # 跨族桥 - fx.rebuild(["breaker_strike"], [], {}) - Kit.check("破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty()) + # 跨族桥(破封一击随封印受理即揭示改版退役——dug_under_seal 事实词除名,改判路径不存在〔封印改 V7〕) # 节奏系 fx.rebuild(["map_exchange"], [], {}) var me_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_stop", {"ap_left": 4}) diff --git a/game/tests/unit/core/effect_words_t1_test.gd b/game/tests/unit/core/effect_words_t1_test.gd index a485f74a..e736c362 100644 --- a/game/tests/unit/core/effect_words_t1_test.gd +++ b/game/tests/unit/core/effect_words_t1_test.gd @@ -21,10 +21,12 @@ func _init() -> void: Kit.check_quiet(EffectEngineScript.EFFECT_KINDS.has("grant_reserve_ap") and EffectEngineScript.EFFECT_KINDS.has("apply_discount"), "换图系新效果在册:grant_reserve_ap/apply_discount〔换图系批 V1〕") Kit.check_quiet(EffectEngineScript.FACTS.has("hp_ratio") and EffectEngineScript.FACTS.has("is_edge_cell") - and EffectEngineScript.FACTS.has("new_resource_type") and EffectEngineScript.FACTS.has("dug_under_seal") + and EffectEngineScript.FACTS.has("new_resource_type") and EffectEngineScript.FACTS.has("ap_exhausted_last") and EffectEngineScript.FACTS.has("ap_left") and EffectEngineScript.FACTS.has("is_blank_zero") and EffectEngineScript.FACTS.has("amount"), - "新事实在册:hp_ratio/is_edge_cell/new_resource_type/dug_under_seal/ap_exhausted_last/ap_left/is_blank_zero/amount〔专长批 V1〕") + "新事实在册:hp_ratio/is_edge_cell/new_resource_type/ap_exhausted_last/ap_left/is_blank_zero/amount〔专长批 V1——dug_under_seal 随破封一击退役除名,退役改 V7〕") + Kit.check_quiet(not EffectEngineScript.FACTS.has("dug_under_seal"), + "退役事实词出册:dug_under_seal(改判路径退役——词表除名防回潮)〔退役改 V7〕") Kit.check_quiet(EffectEngineScript.EFFECT_KINDS.has("heal") and EffectEngineScript.EFFECT_KINDS.has("ac_bonus") and EffectEngineScript.EFFECT_KINDS.has("damage_reduce") and EffectEngineScript.EFFECT_KINDS.has("bonus_delta") and EffectEngineScript.EFFECT_KINDS.has("blast_scaled") and EffectEngineScript.EFFECT_KINDS.has("preview_area") diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd index a312428b..e847e6de 100644 --- a/game/tests/unit/core/feat_vouchers_test.gd +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -288,20 +288,17 @@ func _init() -> void: Kit.check_quiet(o11.perfect_sweep_earned(), "A11 真值:误标 1 − 豁免 1 = 有效 0 且标中≥1 → 完美成立(与 highlight==空 一致)〔卡① N7〕") - # A12 呈现两态文案与 _format_row 一致:两态均零「雷」字面 - var LogViewScript := load("res://presentation/views/battle_log_view.gd") - var lv = LogViewScript.new(db, db.presentation.battle_layout) - var broken_row := {"type": "seal_miss", "source_class": "seal_miss", "origin_cell": [0, 0], - "round_index": 1, "display_name": "封印未中", "value": 0, "highlight": "perfect_broken"} - var forgiven_row := {"type": "seal_miss", "source_class": "seal_miss", "origin_cell": [0, 0], - "round_index": 1, "display_name": "封印未中", "value": 0} - var s_broken := String(lv._format_row(broken_row)) - var s_forgiven := String(lv._format_row(forgiven_row)) - Kit.check_quiet(s_broken.find("完美封印已破") >= 0 and s_broken.find("雷") == -1, - "A12 已破态:_format_row 输出「完美封印已破」且零「雷」字面〔卡① N7〕") - Kit.check_quiet(s_forgiven.find("已豁免") >= 0 and s_forgiven.find("雷") == -1, - "A12 豁免态:_format_row 输出「已豁免」且零「雷」字面〔卡① N7〕") - lv.free() + # A12 呈现两态文案退役改写〔退役改 V-退役卡 R1 波及面——test-conventions §5.5 同生死〕: + # 逐行文字列表退役后 _format_row 行格式化域整体摘除,seal_miss 两态文案(「完美封印已破」/ + # 「已豁免」)随明细带退出玩家面;断言改为退役态防回潮——行格式化方法不在位。 + var LogViewScript: Script = load("res://presentation/views/battle_log_view.gd") + var has_fmt := false + for m in LogViewScript.get_script_method_list(): + if String(m.get("name", "")) == "_format_row": + has_fmt = true + break + Kit.check_quiet(not has_fmt, + "A12 行格式化域已退役(_format_row 不在位——seal_miss 两态文案退出玩家面)〔卡① N7〕") sm.clear_breakpoint() diff --git a/game/tests/unit/core/minesweeper_rules_test.gd b/game/tests/unit/core/minesweeper_rules_test.gd index 96f9c563..55f9a740 100644 --- a/game/tests/unit/core/minesweeper_rules_test.gd +++ b/game/tests/unit/core/minesweeper_rules_test.gd @@ -42,6 +42,10 @@ func _init() -> void: _test_seal_place() _test_reject_out_of_catalog() _test_preview_reveal_readonly() + _test_seal_reveal_identity() + _test_dig_sealed_rejected() + _test_clear_by_seal() + _test_classify_seal_clears() quit(Kit.finish("minesweeper_rules_test")) @@ -133,12 +137,15 @@ func _test_preview_reveal_readonly() -> void: Kit.check("V14 形态A:preview 返回集(真板形态——%d 格)" % prev_a.size(), prev_a.size() >= 0) Kit.check("V14 形态A:调用前后零状态写入(revealed/seal 计数零变化)〔架构整改 V14〕", _rules.revealed_cells().size() == before_revealed and _rules.seal_count() == before_seals) - # 形态 B:挖封印格——本格封印隐式退役只入模拟集,真实封印计数不动 + # 形态 B(改判〔封印改 V14B〕——挖封印格隐式退役前提随「封印受理即揭示」退役): + # preview(封印格) 连锁不越封印——封印格自身零外扩返回空集,真实封印计数不动 var seal_c := _unrevealed_safe() _rules.settle_batch([{"action": "seal_place", "cell": seal_c}]) var sealed_before := _rules.seal_count() var prev_b: Array = _rules.preview_reveal(seal_c) - Kit.check("V14 形态B:preview(封印格) 零状态写入(seal_count 不变——隐式退役只入模拟集)〔架构整改 V14〕", + Kit.check("V14 形态B:preview(封印格) 零外扩(连锁不越封印——返回空集 %d 格)〔封印改 V14B〕" % prev_b.size(), + prev_b.is_empty()) + Kit.check("V14 形态B:preview(封印格) 零状态写入(seal_count 不变)〔封印改 V14B〕", _rules.seal_count() == sealed_before) # 同口径对照:preview(c) = settle_batch(dig c) 后 revealed 差集 ∖ {c}(F2——不含首格与已开格) var dig_c := _unrevealed_safe() @@ -159,3 +166,163 @@ func _test_preview_reveal_readonly() -> void: var _pc: Array = _rules.preview_reveal(_unrevealed_safe()) Kit.check("V14 零事件:preview 全程 cell_revealed 零发射(前后增量 0——实测 %d→%d)〔架构整改 V14〕" % [events_before_previews, events], events == events_before_previews) + +# 未完成安全格集(清图口径=已挖∪已封印——未揭且未封;V3 阶段c/V4 夹具扫描口) +func _incomplete_cells() -> Array: + var out: Array = [] + for c in _board.safe_cells(): + if not _rules.is_revealed(c) and not _rules.has_seal(c): + out.append(c) + return out + + +# 新板重装夹具(V3 阶段c/V4 干净局面——IDLE 装板→round_start 进 ACTIVE;封印/揭示随装板重置) +func _fresh_board(seed_v: int) -> void: + _ae._phase = 0 # Phase.IDLE——夹具直置(前置相位可能处 ACTIVE:fx_reveal 不经行动经济不收轮;notify_combat_end 相位守卫拒 ACTIVE) + var rng := RngServiceScript.new() + rng.set_master_seed(seed_v) + rng.new_adventure(seed_v) + _board = BoardGeneratorScript.new().generate(1, 0, rng.open_stream("board_gen"), _db) + _rules.install_board(_board) + _ae.notify_round_start() + + +# ---- 封印受理即揭示改版(迭代 56 封印动作改版批 V1–V4——身份可查/挖封印格被拒/清图口径/批次语义序)---- + +# 未揭示且未封印的安全格(V1-V4 夹具对象选择口——排除已封印格) +func _unrevealed_unsealed_safe() -> Vector2i: + for c in _board.safe_cells(): + if not _rules.is_revealed(c) and not _rules.has_seal(c): + return c as Vector2i + return Vector2i(-1, -1) + + +# V1 封印受理即揭示:身份当场可查(sealed_identity 与板事实逐位一致)、受理零事件零兑付 +func _test_seal_reveal_identity() -> void: +# 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:52 封印受理那一刻身份当场可见、受理当场零兑付零事件 + _ae.grant_ap(20) + _rules.seal_range_free = true + var safe_c := _unrevealed_unsealed_safe() + var events := 0 + _rules.cell_revealed.connect(func(_evt): events += 1) # 受理零事件哨兵 + var events_before := events + _rules.settle_batch([{"action": "seal_place", "cell": safe_c}]) + Kit.check("V1 前提:安全格封印受理", _rules.has_seal(safe_c)) + # 身份揭示拼装(用户裁 2026-11-08 上限保持 20——sealed_identity 撤出 rules, + # 身份事实=BoardData.cell_identity、封印态=has_seal,两段各归其主) + Kit.check("V1 安全格封印身份 kind=safe 且 digit=板数字逐位一致(%s vs %d)〔封印改 V1〕" % [str(_board.cell_identity(safe_c).get("kind", "")), _board.digit_of(safe_c)], + _rules.has_seal(safe_c) and String(_board.cell_identity(safe_c).get("kind", "")) == "safe" + and int(_board.cell_identity(safe_c).get("digit", -9)) == _board.digit_of(safe_c)) + Kit.check("V1 受理零事件:cell_revealed 零发射(%d→%d)〔封印改 V1〕" % [events_before, events], + events == events_before) + # 危险格封印身份:找未开危险格封印 → kind=mine + var mine_c := Vector2i(-1, -1) + for c in _board.all_cells(): + if _board.is_mine(c) and not _rules.is_revealed(c) and not _rules.has_seal(c): + mine_c = c + break + if mine_c != Vector2i(-1, -1): + _rules.settle_batch([{"action": "seal_place", "cell": mine_c}]) + Kit.check("V1 危险格封印身份 kind=mine〔封印改 V1〕", + _rules.has_seal(mine_c) and String(_board.cell_identity(mine_c).get("kind", "")) == "mine") + Kit.check("V1 身份事实与揭示态独立:未封印格 has_seal=false 但身份可查(板事实恒在)〔封印改 V1〕", + not _rules.has_seal(_unrevealed_unsealed_safe()) + and not _board.cell_identity(_unrevealed_unsealed_safe()).is_empty()) + + +# V2 挖已封印格被拒:原因可辨 target_sealed、封印/揭示/AP 三态零变化 +func _test_dig_sealed_rejected() -> void: +# 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:56 动作合法性表——挖的拒绝情形含已封印 + var sealed_c := Vector2i(-1, -1) + for c in _board.all_cells(): + if _rules.has_seal(c) and not _rules.is_revealed(c): + sealed_c = c + break + Kit.check("V2 前提:已封印未开格在板", _rules.has_seal(sealed_c) and not _rules.is_revealed(sealed_c)) + _rules.dig_range_bonus = 99 # 夹具免邻接(公开属性直置——seal_range_free 先例同款;挖掘射程非判据面) + _ae.grant_ap(20) + var ap_before: int = _ae._ap + var out: Array = _rules.settle_batch([{"action": "dig", "cell": sealed_c}]) + var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} + Kit.check("V2 挖封印格被拒且原因可辨(sub_reason=target_sealed——实测 %s)〔封印改 V2〕" % String(o.get("sub_reason", "")), + String(o.get("outcome", "")) == "rejected" and String(o.get("sub_reason", "")) == "target_sealed") + Kit.check("V2 拒绝零副作用:封印态/揭示态/AP 三态零变化〔封印改 V2〕", + _rules.has_seal(sealed_c) and not _rules.is_revealed(sealed_c) and _ae._ap == ap_before) + _rules.dig_range_bonus = 0 # 还原射程(后续夹具不吃豁免) + + +# V3 清图口径换代:封印可完成清图(封掉最后安全格→cleared 收束);非末封印不清图 +func _test_clear_by_seal() -> void: +# 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:139 清图=全部安全格完成,完成=挖开或封印 + _ae.grant_ap(20) + _rules.seal_range_free = true + # 阶段 a:封一张非末安全格 → clear_map_cleared 仍 false(封印计入完成但未清完) + var seal_c := _unrevealed_unsealed_safe() + _rules.settle_batch([{"action": "seal_place", "cell": seal_c}]) + Kit.check("V3 阶段a 前提:封印受理", _rules.has_seal(seal_c)) + Kit.check("V3 阶段a:非末封印后清图未达成(clear_map_cleared=false)〔封印改 V3〕", + not _rules.clear_map_cleared()) + # 阶段 b:fx_reveal 除 seal_c 外全部未完成安全格 → 唯一未完成格=已封印的 seal_c → 封印计入完成 + for c in _board.safe_cells(): + if c != seal_c and not _rules.is_revealed(c) and not _rules.has_seal(c): + _rules.fx_reveal(c) + Kit.check("V3 阶段b:唯一未完成格=已封印格 → clear_map_cleared=true〔封印改 V3〕", + _rules.clear_map_cleared()) + # 阶段 c:另起干净新板——逐格封至清图(每封一批次真实入口):非末封零 round_end、 + # 末封恰发一条 path_tag=cleared 的轮末事件(封印后清图判定触发——轮末事件 carry 清图) + _fresh_board(81003) + _ae.grant_ap(60) + _rules.seal_range_free = true + var end_seen: Array = [] + _rules.round_end_board.connect(func(p): end_seen.append(String(p.get("path_tag", "")))) + var guard := 0 + while not _rules.clear_map_cleared() and guard < 200: + guard += 1 + var t: Vector2i = _incomplete_cells()[0] if not _incomplete_cells().is_empty() else Vector2i(-1, -1) + if t == Vector2i(-1, -1): + break + _rules.settle_batch([{"action": "seal_place", "cell": t}]) + Kit.check("V3 阶段c:逐格封至清图达成(%d 封——封印可完成清图)〔封印改 V3〕" % guard, + _rules.clear_map_cleared()) + Kit.check("V3 阶段c:清图收束恰一次且 path_tag=cleared(round_end 序列=%s)〔封印改 V3〕" % str(end_seen), + end_seen.size() == 1 and String(end_seen[0]) == "cleared") + + +# V4 批次语义序:同批「停手 + 能清图的封」提交 → 赢家=清图收束而非停手 +func _test_classify_seal_clears() -> void: +# 判据出处: design/gdd/systems/排雷循环/排雷玩法.md:167 最先=能清图的挖或封——防耗尽优先把清图高光结算成最差结局 + # 收敛未完成格到恰 1 格(封它即清图;fx_reveal 连锁不可控——逐格揭至剩 1,连锁越格则换种子重试) + var left: Array = [] + var last_c := Vector2i(-1, -1) + for seed_i in range(3): + _fresh_board(81004 + seed_i) + _ae.grant_ap(60) + _rules.seal_range_free = true + left = _incomplete_cells() + var guard2 := 0 + while left.size() > 1 and guard2 < 400: + _rules.fx_reveal(left[0]) + left = _incomplete_cells() + guard2 += 1 + if left.size() == 1: + last_c = left[0] + break + Kit.check("V4 前提:未完成格收敛到恰 1 格(实测 %d——封它即清图)" % left.size(), + left.size() == 1) + if left.size() != 1: + quit(Kit.finish("minesweeper_rules_test")) + return + var intents: Array = [{"action": "stop"}, {"action": "seal_place", "cell": last_c}] + # 同批「停手+能清图的封」:末格封 cls 0 最先执行→cleared 收轮;stop cls 1 voided + var end_seen: Array = [] + _rules.round_end_board.connect(func(p): end_seen.append(String(p.get("path_tag", "")))) # append 捕获引用(lambda 内重绑定不回写) + var outcomes: Array = _rules.settle_batch(intents) + Kit.check("V4 赢家=清图封:round_end path_tag=cleared 而非 stopped(round_end 序列=%s)〔封印改 V4〕" % str(end_seen), + end_seen.size() == 1 and String(end_seen[0]) == "cleared") + Kit.check("V4 清图封印受理(末格 %s 在封印态)〔封印改 V4〕" % str(last_c), _rules.has_seal(last_c)) + # outcomes 按执行序承载(cls 升序)——赢家=cls 0 的清图封居首;stop 与非末封在其后 voided + var o_winner: Dictionary = (outcomes[0] as Dictionary) if not outcomes.is_empty() else {} + Kit.check("V4 末格封意图受理(batch 赢家=清图封居执行序首——实测 %s)〔封印改 V4〕" % String(o_winner.get("outcome", "")), + String(o_winner.get("outcome", "")) == "executed") + Kit.check("V4 其余意图 voided(清图收轮后批次余项作废)〔封印改 V4〕", + outcomes.size() >= 2 and String((outcomes[outcomes.size() - 1] as Dictionary).get("outcome", "")) == "voided") diff --git a/game/tests/unit/presentation/battle_log_zero_text_test.gd b/game/tests/unit/presentation/battle_log_zero_text_test.gd new file mode 100644 index 00000000..789c66cc --- /dev/null +++ b/game/tests/unit/presentation/battle_log_zero_text_test.gd @@ -0,0 +1,110 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 结算期 B 区零逐行文字断言(逐行结算排播退役——08-Alpha v1.29 删「逐行结算排播」行、 +# 用户裁定 2026-11-07 撤逐行滚动文字列表保留伤害飘字与攻击特效): +# ① 真实结算产生逐行后(排播队列非空前提),B 区 RichTextLabel 行文本恒空(零逐行追加); +# ② 排播泵消费期间与 flush 排空后 parsed_text 恒空——step_row 消费零产生 append_text 行; +# ③ FX 排播链仍在(伤害行驱动演出层弹道/数字——演出不随文字退役,〔退役改 V-退役卡 R1〕)。 +# 载体:main.tscn 实例(组合根全链在座),真实冒险驱动至首战结算后断言(同 pacing_queue_test 形态)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/battle_log_zero_text_test.gd(expect 0) +extends SceneTree + +var _problems: Array = [] + + +func check(cond: bool, msg: String) -> void: +# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组) + if not cond: + _problems.append(msg) + + +func _init() -> void: +# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组) + _smoke_settle_zero_rows() + + +func _smoke_settle_zero_rows() -> void: +# 判据出处: design/gdd/08-Alpha版本标准.md:154 战斗演出链(弹道 / 受击 / 血条 / 灰化)在役; +# 逐行结算排播行已随 v1.29 退役——结算期 B 区零逐行文字、FX 排播链保持 + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + for p in ["user://test_log_zero_text_save.json", "user://test_log_zero_text_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_log_zero_text_save.json") + sm.meta_state.mark_first_adventure_done() + 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 + await process_frame + var battle = main.run_scope.runner.current_battle() + if battle == null: + _problems.append("首战未开打(零文字断言无载体)") + _finish() + return + var rows_seen: Array = [] + battle.step_row.connect(_count_row.bind(rows_seen)) + # 理性策略挖掘至受理一动作(auto_player 单一实现复用——同 pacing_queue_test 两步消费) + var ms = battle.minesweeper + 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 + if battle.action_economy.phase == "Active": + ms.settle_batch([{"action": "stop"}]) + if not acted: + _problems.append("首战无受理动作(零文字断言无行)") + _finish() + return + await process_frame + check(rows_seen.size() > 0, "结算行已产生(断言前提:%d 行)" % rows_seen.size()) + var log_view = main.ui.battle.log_view + # 判据绑定面 = append_text 调用计数=0(「不绑终屏态」):进节点叙述行(node_banner,真实入口 + # 既有叙述面、非 step_row 消费产物)落带后采基线,断言 step_row 消费全程文本冻结零追加。 + var base_text: String = log_view.parsed_text() + # ---- 时点一:行已入队、排播泵消费中——零新增行 ---- + check(log_view.parsed_text() == base_text, "结算消费中 B 区零新增行(时点一:逐行追加零发生)〔退役改 V-退役卡 R1〕") + await process_frame + await process_frame + # ---- 时点二:泵已消费若干行——仍零新增(文字入带调用已撤,泵只驱动演出链)---- + check(log_view.parsed_text() == base_text, "泵消费数帧后 B 区零新增行(时点二)〔退役改 V-退役卡 R1〕") + # ---- flush 同步排空(模态前置契约路径)后再采——仍零新增 ---- + main.ui.screens.row_pacer.flush() + check(main.ui.screens.row_pacer.pending_rows().is_empty(), "flush 后队列排空(完整性前提)") + check(log_view.parsed_text() == base_text, "排空后 B 区零新增行(时点三:step_row 消费 append_text 计数=0)〔退役改 V-退役卡 R1〕") + _finish() + + +func _count_row(row: Dictionary, arr: Array) -> void: + arr.append(row.duplicate(true)) + + +func _finish() -> void: +# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组) + for p in ["user://test_log_zero_text_save.json", "user://test_log_zero_text_save.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + if _problems.is_empty(): + print("[battle_log_zero_text_test] PASS — 结算期 B 区零逐行文字 全绿") + quit(0) + else: + for p in _problems: + push_error("[battle_log_zero_text_test] FAIL — " + p) + print("[battle_log_zero_text_test] FAIL — " + p) + quit(1) diff --git a/game/tests/unit/presentation/hud_presenter_test.gd b/game/tests/unit/presentation/hud_presenter_test.gd index 6c779854..289bd451 100644 --- a/game/tests/unit/presentation/hud_presenter_test.gd +++ b/game/tests/unit/presentation/hud_presenter_test.gd @@ -27,7 +27,8 @@ func _test_reject_text_by_reason() -> void: var cases := { "not_adjacent": "不能执行:目标与小人不相邻", "target_revealed": "不能执行:目标已开", - "seal_present": "不能封印:此格已有封印印记(封印为一次性承诺——相邻时左键直接挖开)", + "seal_present": "不能封印:此格已有封印印记(封印受理即揭示——印记随图存续)", + "target_sealed": "不能挖:此格已封印(封印受理即揭示——不可再挖开)", } var bad: Array[String] = [] for sub in cases: diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index 5b093080..30f7e13e 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -15,11 +15,18 @@ 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: @@ -105,19 +112,12 @@ func _init() -> void: main.ui.screens.row_pacer.flush() var snap_after: String = JSON.stringify(battle.diagnostic_snapshot()) check(snap_before == snap_after, "排空前后核心诊断快照逐位一致(AC⑤ 演出关停零分叉)") - # ---- 断言②:零丢行(结构判据:排空后队列恒空 + 末信号行原文在带——append_row 直落不吞行)---- + # ---- 断言②:零丢行(逐行文字列表退役后改判〔退役改 V-退役卡 R2〕:结构判据=排空后队列恒空, + # 行的落地面由「原文在明细带」改为「逐行进 FX 链」——由下方 _assert_fx_chain 对全行集断言承接)---- check(rows_seen.size() > 0, "结算行已产生(断言前提)") check(main.ui.screens.row_pacer.pending_rows().is_empty(), "排空后队列恒空(flush 完整性)") - var last_text := "" - for i in range(rows_seen.size() - 1, -1, -1): - var cand_row: Dictionary = rows_seen[i] - last_text = String(cand_row.get("text", "")) - if last_text == "": - last_text = String(cand_row.get("display_name", "")) - if last_text != "": - break - check(last_text != "" and main.ui.battle.log_view.parsed_text().contains(last_text), - "末信号行原文在明细带(排空不吞行:『%s』)" % last_text) + # ---- 断言②改判承接:FX 链逐行收拍(真实行集驱动——每行一拍站桩反馈,弹道/标出按型计数逐位对上)---- + _assert_fx_chain(main, rows_seen) # ---- 断言③:段间延迟取数面双态 ---- UiKit.motion_scale = 1.0 var d_on: float = main.pace_delay_for("damage", "heal") @@ -156,6 +156,110 @@ 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"]: diff --git a/game/tests/unit/presentation/player_copy_sweep_test.gd b/game/tests/unit/presentation/player_copy_sweep_test.gd index 9cbb480c..1bc51d19 100644 --- a/game/tests/unit/presentation/player_copy_sweep_test.gd +++ b/game/tests/unit/presentation/player_copy_sweep_test.gd @@ -76,7 +76,10 @@ func _sweep_legend() -> void: seal_row = e break Kit.check("图例封印行含「标空」(u4——与「标中」成对)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("标空")) - Kit.check("图例封印行含「受挖掘惩罚」(u4 雷字清零换代)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("受挖掘惩罚")) + # 文案换代〔封印改 V10〕——图例封印行新口径:受理即揭示、已封印格零动作(旧「受挖掘惩罚」句随改判路径退役) + Kit.check("图例封印行含「受理即揭示」且零雷字(u4 清零换代〔封印改 V10〕)〔wa5〕", + String(seal_row[2] if seal_row.size() > 2 else "").contains("受理即揭示") + and not String(seal_row[2] if seal_row.size() > 2 else "").contains("雷")) for w in ["插错", "挖雷"]: Kit.check("图例封印行零「%s」〔wa5〕" % w, not String(seal_row[2] if seal_row.size() > 2 else "").contains(w)) @@ -101,11 +104,17 @@ func _sweep_source_names() -> void: row_view._hide_tip() Kit.check("结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕", String(_db.combat.settlement.core.source_label_map.get("mine_hit", "")) == "危险格") - var log_view = load("res://presentation/views/battle_log_view.gd").new(_db, _db.presentation.battle_layout) - var row_txt := String(log_view._format_row({"type": "effect", "source_class": "mine_hit", - "display_name": "挖掘反噬", "value": -5, "pool_value": -5, "round_index": 1})) - Kit.check("战斗日志 mine_hit 来源行含「(危险格)」(u6 消费半——_format_row 生产路径)〔wa7〕", - row_txt.contains("(危险格)")) + # u6 消费半退役改写〔退役改 V-退役卡 R1——test-conventions §5.5 同生死〕:逐行文字列表退役后 + # 行格式化域(_format_row)摘除,来源标签玩家可见面只余状态槽 tooltip(u5 承接)与数据表; + # 断言改为退役态防回潮——行格式化方法不在位(防旧行文本呈现路径复活)。 + var LogViewScript: Script = load("res://presentation/views/battle_log_view.gd") + var has_fmt := false + for m in LogViewScript.get_script_method_list(): + if String(m.get("name", "")) == "_format_row": + has_fmt = true + break + Kit.check("行格式化域已退役(_format_row 不在位——来源标签只由数据表与 tooltip 承载)〔wa7〕", + not has_fmt) # ⑦⑧ 已开危险格字形族(u7/u8/u9——BoardView rebuild 真实刷新路径+源 token 扫描) diff --git a/game/tests/unit/presentation/playtest_logger_step_fields_test.gd b/game/tests/unit/presentation/playtest_logger_step_fields_test.gd new file mode 100644 index 00000000..b3ff11a4 --- /dev/null +++ b/game/tests/unit/presentation/playtest_logger_step_fields_test.gd @@ -0,0 +1,108 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 日志 step 行零文案与全字段落盘断言(结算期文字列表退役批 T2——日志零文案只存字段): +# 真实入口 = sim 采集链(LevelAutoplay.run_level 挂 PlaytestLogger.start_session → 真实结算 → +# close_session 落盘 JSONL 读回),断言: +# ① 全型 step 行零中文文案(text / display_name 两键不存在); +# ② 公共七字段(kind/type/source/cell/round/value/highlight)与平铺原件(origin_cell/round_index/ +# source_class)逐型在位——日志逐行全字段落盘义务不变(公共字段集除文案两键外零删减); +# ③ payload 键抽样在位(damage 行 roll/ac/tier/normal/pierce/magic/baseline——改前键集实证在册, +# 反事实精确重算数据面不被零文案改动静默吞键)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/playtest_logger_step_fields_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _problems: Array = [] + + +func check(cond: bool, msg: String) -> void: +# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组) + if not cond: + _problems.append(msg) + + +func _init() -> void: +# 判据出处: design/gdd/08-Alpha版本标准.md:154 演出链在役(逐行结算排播已随 v1.29 退役)—— +# 日志零文案只存字段(text/display_name 不落盘),其余全字段与 payload 逐键保留 + var db = ConfigDBScript.new() + if not db.load_tables("res://data/tables", true): + _problems.append("数值表加载失败(夹具前提)") + _finish() + return + _assert_no_text_and_full_payload(db) + _finish() + + +func _assert_no_text_and_full_payload(db) -> void: +# 判据出处: design/gdd/systems/战斗循环/战斗结算.md:87 显示名等玩家可见文案是数据面字段(日志消费端零文案) + var logger = load("res://presentation/playtest_logger.gd").new() + logger.ap_per_round = int(db.run.action_economy.ap_per_round) # 注入序前置(守卫拒后开——R5 必填) + var log_path: String = logger.start_session(4242) + var autoplay = load("res://sim/level_autoplay.gd") + var st = autoplay.run_level(db, 4242, 1, 0, 0, false, logger, "normal", "min") + logger.close_session() + check(bool(st.get("terminated", false)) or int(st.get("rounds", 0)) > 0, + "真实结算已跑(夹具前提:rounds>0——result=%s log_path=%s)" % [String(st.get("result", "?")), String(st.get("log_path", "?"))]) + # 落盘 JSONL 读回(start_session 返回路径——采集链同源) + var f := FileAccess.open(log_path, FileAccess.READ) + check(f != null, "日志文件可读(%s)" % log_path) + if f == null: + return + var steps: Array = [] + while not f.eof_reached(): + var line := f.get_line() + if line.strip_edges() == "": + continue + var parsed: Variant = JSON.parse_string(line) + if parsed is Dictionary and String(parsed.get("kind", "")) == "step": + steps.append(parsed) + f.close() + check(steps.size() > 0, "step 行非空(断言前提:%d 行)" % steps.size()) + # ---- ① 全型零文案(text / display_name 两键不存在)---- + var text_hits := 0 + var display_hits := 0 + for s in steps: + if (s as Dictionary).has("text"): + text_hits += 1 + if (s as Dictionary).has("display_name"): + display_hits += 1 + check(text_hits == 0, "全 step 行零 text 键(命中 %d/%d)〔退役改 V-退役卡 R3〕" % [text_hits, steps.size()]) + check(display_hits == 0, "全 step 行零 display_name 键(命中 %d/%d)〔退役改 V-退役卡 R3〕" % [display_hits, steps.size()]) + # ---- ② 公共七字段 + 平铺原件逐型在位 ---- + var common := ["kind", "type", "source", "cell", "round", "value", "highlight", + "origin_cell", "round_index", "source_class"] + var misses: Array[String] = [] + for s in steps: + for k in common: + if not (s as Dictionary).has(k): + misses.append("%s 缺 %s" % [String(s.get("type", "?")), k]) + check(misses.is_empty(), "公共字段与平铺原件逐行在位(缺:%s)〔退役改 V-退役卡 R3〕" % [", ".join(misses)]) + # ---- ③ damage 行 payload 键并集抽样(反事实精确重算数据面在册——单行键随行型分布, + # 并集才是改前契约形态:掷点键逐行必有、分量键按攻击来源分布)---- + var dmg_union := {} + var dmg_seen := 0 + for s in steps: + if String(s.get("type", "")) == "damage": + dmg_seen += 1 + for k in (s as Dictionary).keys(): + dmg_union[k] = true + check(dmg_seen > 0, "damage 行在册(抽样前提:%d 行)" % dmg_seen) + var payload_missing: Array[String] = [] + for k in ["roll", "ac", "tier", "normal", "baseline"]: + if not dmg_union.has(k): + payload_missing.append(k) + check(payload_missing.is_empty(), + "damage 行 payload 键并集在位(缺:%s)〔退役改 V-退役卡 R3〕" % [", ".join(payload_missing)]) + + +func _finish() -> void: +# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组) + if _problems.is_empty(): + print("[playtest_logger_step_fields_test] PASS — 日志 step 行零文案与全字段落盘 全绿") + quit(0) + else: + for p in _problems: + push_error("[playtest_logger_step_fields_test] FAIL — " + p) + print("[playtest_logger_step_fields_test] FAIL — " + p) + quit(1) diff --git a/game/tests/unit/presentation/seal_mark_params_test.gd b/game/tests/unit/presentation/seal_mark_params_test.gd index 8000c9c4..e7653a63 100644 --- a/game/tests/unit/presentation/seal_mark_params_test.gd +++ b/game/tests/unit/presentation/seal_mark_params_test.gd @@ -14,6 +14,7 @@ func _init() -> void: _test_param_values() _test_shader_uniforms() _test_fog_gate() + _test_seal_on_revealed_visible() quit(Kit.finish("seal_mark_params_test")) @@ -54,3 +55,16 @@ func _test_fog_gate() -> void: u.seal_on = false Kit.check("撤 seal_on 即撤子件〔seal批 V4〕", u._seal_layer == null) u.free() + + +# R8 新组合态:seal_on 与已揭示同真时印记子件仍可见(封印受理即揭示改版——印记可见性不依赖 +# 揭示态;内部字段直查=受控例外同 _test_fog_gate——子件可见性无公开可观测面,对照表随行申报) +func _test_seal_on_revealed_visible() -> void: +# 判据出处: design/ux/battle.md「封印格呈现预览」受理即揭示——印记承载面换代(已揭示格承载合法) + var u: Node2D = CELL_UNIT.new() + u.seal_on = true + Kit.check("R8 前提:seal_on 挂封印子件〔封印改 V8〕", u._seal_layer != null) + u.fog_state = 0 + Kit.check("R8 新组合态:seal_on 与揭示同真印记仍可见(非雾态——承载面不因揭示态撤印)〔封印改 V8〕", + u._seal_layer != null and u._seal_layer.visible == true) + u.free() diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd index 0e0093ef..e5a30f51 100644 --- a/game/tests/unit/presentation/theme_projection_test.gd +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -378,51 +378,92 @@ func _smoke_type_glow() -> void: viewB.free() -# ---- 战斗日志视图·行格式化呈现层同步闸(真人试玩卡断 bug 回归: -# 伤害行 attach 字段变更后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach, -# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕, -# 本段把现行行形态逐型喂进真实视图:拆行残留与行字段错位在此转红)---- +# ---- 行消费 FX 链呈现层同步闸(原「行格式化 9 类同步闸」退役改写〔退役改 V-退役卡 R1〕: +# 逐行文字列表退役后行格式化域摘除,行协议的呈现层消费面 = row_pacer FX 链——原防 +# 「格式化 Invalid access 卡死结算段」的盲区检测法(解析文本增长)等价迁移为 FX 落拍计数增长: +# 行协议 9 类逐型经排播泵消费零炸、每型落拍一拍站桩反馈)---- func _smoke_battle_log_view() -> void: -# 判据出处: design/gdd/systems/表现与支撑/HUD框架.md:272 (明细带行协议组) - print("--- 战斗日志视图行格式化(行协议 9 类呈现层同步闸)---") - var view = load("res://presentation/views/battle_log_view.gd").new(_db, _db.presentation.battle_layout) - # 敌方行动伤害行(现行形态:baseline 在场、attach 已出协议——修复前此行即 Invalid access) - view.append_row({"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角", - "baseline": 3, "roll": 15, "ac": 12, "tier": 100, - "source_class": "enemy_action", "round_index": 2}) - var enemy_txt: String = view.parsed_text() - Kit.check("敌方伤害行格式化(baseline 行不读退役 attach)", - enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12")) - Kit.check("退役伏击/基线拆行文案不再出现", - not enemy_txt.contains("伏击") and not enemy_txt.contains("基线")) - # 主角攻击行·单分量模型(payload 只带实际攻击类型一键——pierce/magic 多键即崩) - view.append_row({"type": "damage", "display_name": "主角攻击", "value": 4, "pierce": 4, - "roll": 20, "ac": 13, "tier": 100, "crit": true, "killed": true, - "source_class": "hero_attack", "round_index": 2}) - var hero_txt: String = view.parsed_text() - Kit.check("主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)", - hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■")) - Kit.check("退役三分量拆行文案不再出现", not hero_txt.contains(" + 穿刺 ")) - # 未生效行聚合(pattern 3 聚合纪律——同键两行呈现 ×2 不逐行刷屏) - view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1}) - view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1}) - view.append_row({"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1}) - var agg_txt: String = view.parsed_text() - Kit.check("未生效行聚合计数 ×2 呈现", agg_txt.contains("⚠") and agg_txt.contains("×2")) - # 其余行型逐型喂入:格式化中途夭折 = 解析文本零增长(脚本错误静默吞断言的盲区由此转为可见 FAIL) - var rest := { +# 判据出处: design/gdd/08-Alpha版本标准.md:154 战斗演出链(弹道 / 受击 / 血条 / 灰化)在役—— +# 行协议 9 类逐型经排播泵消费零炸、每型落拍(明细带行协议呈现已退役) + print("--- 行消费 FX 链同步闸(行协议 9 类逐型零炸)---") + var rp = load("res://presentation/row_pacer.gd").new() + var fstage := _FxChainFakeStage.new(null, _db.presentation.battle_layout) + var ffx := _FxChainFakeFx.new() + var fzone := _FxChainFakeZone.new(_db.presentation.battle_layout) + fzone.fx = fzone.board_fx + fzone.toast = fzone.toast_stack + rp.stage = fstage + rp.board_view = BoardView.new() + rp.settlement_fx = ffx + rp.board_zone = fzone + rp.refresh_request = func() -> void: pass + rp.pump_ended = func() -> void: pass + # 行协议 9 类逐型喂入(现行形态样本——敌方伤害行 baseline 在场 attach 已出协议; + # 格式化中途夭折 = 落拍零增长,脚本错误静默吞断言的盲区由此转为可见 FAIL) + var samples := { + "damage": {"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角", + "baseline": 3, "roll": 15, "ac": 12, "tier": 100, + "source_class": "enemy_action", "round_index": 2}, "effect": {"type": "effect", "display_name": "攻击强化", "value": 1, "pool_value": 2, "effect_id": "attack_up", "source_class": "seal_hit", "round_index": 1}, + "heal": {"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1}, + "reward_ineffective": {"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1}, "seal_miss": {"type": "seal_miss", "display_name": "封印未中", "source_class": "seal_miss", "round_index": 1}, "cast": {"type": "cast", "display_name": "卓尔精灵 施放 毒雾", "source_class": "enemy_action", "round_index": 3}, "decay": {"type": "decay", "value": 1, "round_index": 3}, "verdict": {"type": "verdict", "display_name": "下一轮次", "value": 0, "round_index": 3}, "highlight": {"type": "highlight", "display_name": "完美封印!清图奖励 ×2", "value": 2, "source_class": "clear_reward", "round_index": 4}, } - for row_type in rest: - var before: int = view.parsed_text().length() - view.append_row(rest[row_type]) - Kit.check("行型 %s 格式化落地(解析文本增长)" % String(row_type), - view.parsed_text().length() > before) + for row_type in samples: + var before: int = fstage.fx_rows.size() + rp.enqueue(samples[row_type]) + rp.flush() # rp 未进树——同步排空消费(pacing_queue_test 同款) + Kit.check("行型 %s FX 落拍(站桩反馈计数增长)" % String(row_type), + fstage.fx_rows.size() > before) + Kit.check("逐型全落拍(9 类行协议消费零炸)", fstage.fx_rows.size() == samples.size()) + rp.free() + + +# FX 链逐型闸记参假件(pacing_queue_test 同款形态——本套件独立定义防跨套件耦合) +class _FxChainFakeStage extends BattleStageView: + var fx_rows: Array[Dictionary] = [] + + 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: + return Vector2(10, 10) + + func hero_standee_center() -> Vector2: + return Vector2(10, 10) + + +class _FxChainFakeFx extends SettlementFxView: + func play_projectile(_from: Vector2, _to: Vector2, _glyph: String, _col: Color, _crit: bool = false) -> void: + pass + + func play_damage_number(_pos: Vector2, _text: String, _col: Color, _crit: bool = false) -> void: + pass + + func play_status_pulse(_pos: Vector2, _col: Color) -> void: + pass + + +class _FxChainFakeZone extends BoardZoneView: + var board_fx := _FxChainFakeBoardFx.new() + var toast_stack := _FxChainFakeToast.new() + + +class _FxChainFakeBoardFx extends BoardFxView: + func play_perfect_sweep() -> void: + pass + + func play_seal_hit_projectile(_heavy: bool) -> void: + pass + + +class _FxChainFakeToast extends ToastStack: + func show_toast(_text: String, _tone: String = "info") -> void: + pass # ---- 开场行后缀尾接协议(迭代36 wc1 静态半——level_open_line 第五参布尔直控尾接,签名零变; diff --git a/reports/iter56-代拍板追认单-2026-11-08.md b/reports/iter56-代拍板追认单-2026-11-08.md new file mode 100644 index 00000000..68455e71 --- /dev/null +++ b/reports/iter56-代拍板追认单-2026-11-08.md @@ -0,0 +1,46 @@ +# 迭代 56 代拍板追认单 + +> 2026-11-08 汇总(实现批代拍板随 commit 留痕,本单逐条呈用户追认——超白名单事项另见「实现决定记录」)。 + +## 卡1 白名单内代拍板(需求卡片-结算期文字列表退役批 §12) + +| # | 白名单项 | 实际裁决 | 落点 | +|---|---|---|---| +| 1 | 新增测试函数命名与落点 | `battle_log_zero_text_test.gd`(presentation/)、`playtest_logger_step_fields_test.gd`(presentation/)、`reward_ineffective_reason_test.gd`(battle_settlement/)、`tools/analyze_parity_check.py`——全落卡内 §7 指定目录 | f0390714/T2/T3 提交 | +| 2 | battle_log_view 去留二选一 | **选「只留摘要位」**:视图壳/摘要位/append_bb 叙述行/set_mode 保留;逐行流与行格式化域(_fmt_*/AGG 聚合/_round_rows)删净。整体退役分支否决原因=五处冒险叙述行无处可迁(迁置=改呈现面超卡) | f0390714 | +| 3 | reason 四枚举键写法 | 照现役 _emit_row extra 模式**字面量直写**六发射点(零新 helper 零新常量);取值 full_layers/full_hp/no_enemy_alive/hero_down | T2 提交 | + +## 卡2 白名单内代拍板(需求卡片-封印动作改版批 §11) + +| # | 白名单项 | 实际裁决 | 落点 | +|---|---|---|---| +| 1 | 拒绝原因英文串定名 | `target_sealed`(保持卡串,零拼写微调) | 2d8a4d07 | +| 2 | 拒绝/悬停 toast 与图例最终措辞 | seal_present 句「不能封印:此格已有封印印记(封印受理即揭示——印记随图存续)」;target_sealed 新句「不能挖:此格已封印(封印受理即揭示——不可再挖开)」;图例封印行「封印印记(受理即揭示——落定当场可见格下身份,随图存续不能拔)——轮末自动判定标中/标空;已封印格零动作(不可再挖开)」;提示条「左键 挖掘 / 移动 · 右键 封印(受理即揭示·不可逆)」 | T3 提交 | +| 3 | 测试函数命名与断言组织 | 对照表点名函数名全数照用(_test_seal_reveal_identity/_test_dig_sealed_rejected/_test_clear_by_seal/_test_classify_seal_clears/_test_seal_on_revealed_visible);V14 形态 B 改判与 V9 case④c 为申报内改判的组织细节 | 2d8a4d07/T4 提交 | +| 4 | 色牌/装饰渲染细节 | 取现行 ui_kit/art-bible 现行值(COL_FLOOR/COL_BRICK/SealMarkLayer 参数化),零新常量 | T3 提交 | +| 5 | sim 补偿注入形态与常量初值 | 形态=「错触直挖」(封印已证雷意图按概率转同格挖——目标未封未揭,新规则下合法路径);初值 NORMAL_MISDIG_P=0.10(承旧注入 0.10——同构期望);计数字段沿用 mine_slips(锚对比列对齐)。**偏差读数已回填**:改前代码现跑踩雷 6.48/局 → 新锚 5.40/局(-1.08——含策略对齐合成效应非纯注入贡献;注入触发单局实测 slips=5) | T6 提交+实现报告 §四 | +| 6 | mac golden 缺件处理 | mac 三件缺件记遗留(渲染闸 SKIP 不判红——T7 预案兑现) | T7 提交 | + +## 实现决定记录(白名单外,凭规约条款自判——逐条列备案) + +| # | 事项 | 判据与依据 | 落点 | +|---|---|---|---| +| 1 | **sealed_identity 落位重设计**(撤出 minesweeper_rules、BoardData 新增 cell_identity) | 用户裁决 2026-11-08「上限保持 20」覆盖卡2 §12 宿主指定;预检失实根因=写卡段「远低于 20」凭印象未实跑(基线恰 20 贴线);停机呈报后用户裁「你自己重新设计」 | T1b 提交 | +| 2 | **S13 三条新违规拆 helper 修净**(_classify_seal/_seal_face_reject 私有 helper) | 卡2 §14「预期只降不升」+gdstyle「新码须修到基线口径内」——非阈值修订非基线新增,合规修复 | T1a 提交 | +| 3 | **feat_vouchers_test A12 断言退役改写**(_format_row 不在位防回潮断言) | 申报外波及面;test-conventions §5.5 换代同生死(旧文案呈现路径摘除=断言对象退役);替代断言=退役态防回潮 | T1 提交 | +| 4 | **pacing_queue_test 断言②改判**(「末行原文在明细带」→FX 链承接 _assert_fx_chain) | 卡1 §10 申报面(:108-120 文字呈现部分)——改写为退役后正向断言,FX 计数从行集独立推导(不镜像实现) | T1 提交 | +| 5 | **theme_projection_test 行协议闸改写**(9 类逐型 FX 落拍闸) | 同 §5.5——格式化域摘除后盲区检测法(解析文本增长)等价迁移为落拍计数增长;原防「格式化炸结算段」的闸价值保留 | T1 提交 | +| 6 | **ui_click_probe wr1-b 反转+开场行判定面换代**(最新行本体判定) | T1 波及(clear 移除破坏「全文本计数」判定前提+摘要功能退役使 wr1-b 语义反转)——断言意图保留、判定面换代 | f0390714 | +| 7 | **adventure_feats_test 满配种子 42→43+表行数 37→36 判值换代** | feats 删行使 RNG 流消费序列变,42 路线漂移为 timeout(主区基线 42 绿对照坐实非行为回归);§5.5 同生死——种子钉值绑旧表行集 | T2+T5 提交 | +| 8 | **ui_click_probe 行为②随机撤封印段摘除** | 卡2 T4 点名「行为②撤封印死路段清理」——申报内 | T4 提交 | +| 9 | **R6 golden 实测零漂移零重录** | settlement_wiring_snapshot 内嵌 golden 在「挖+封+停」驱动序下与改前逐位一致(驱动序不含挖封印格——T1 后该路径被拒)——重录义务免除,读数绿在案 | T2 验收 | +| 10 | **卡1 R4 判读等价基线快照重出**(改前分析器经 git show 提取补丁件跑出完整快照) | T0 落盘的历史锚快照被 head 管道 SIGPIPE 截断(60 行截断)——R4 读数①②需完整改前基线;改前分析器(master 字节)跑出 3394/149 行快照替换,sim-data 留盘 | T3 提交 | +| 11 | **ui_probe:133-135 与 ui_click_probe:423/:473/:616/:798-844 零改动** | 卡1 §10 申报清单列名面实况核查:不依赖退役面(:133-135 读 pending_rows 与尺寸;:423/:473 savesel 段无 battle_log 引用;:616 全文本判首个冒险无残留仍安全)——无文字断言可改写=零改动 | 实现报告对照表登记 | + +## 体验验证清单登记项(真人目检——登记不阻塞,实机目检后由用户销项) + +1. 结算期 B 区零逐行文字(FX 排播链仍逐拍演出)——卡1 R1 体验面 +2. 伤害飘字/弹道/站桩反馈在新排播形态下的观感——卡1 R2 体验面 +3. 封印受理瞬间揭示的观感(地板+身份字形+法阵叠加)——卡2 R8 体验面 +4. 封印格悬停禁字与图例新句——卡2 R9/R10 体验面 +5. 清图高光在封印完成清图时触发——卡2 R3 体验面 diff --git a/reports/iter56-反哺点-2026-11-08.md b/reports/iter56-反哺点-2026-11-08.md new file mode 100644 index 00000000..fa8b0565 --- /dev/null +++ b/reports/iter56-反哺点-2026-11-08.md @@ -0,0 +1,28 @@ +# 迭代 56 反哺点清单 + +> 2026-11-08 采集(实现批产出——设计权威文档实现批禁写,逐条经用户裁决后由文档批落文)。 +> 卡2 预期零反哺点(设计面前置闭合)兑现:封印改版的设计条款无差异发现;下列各条全部来自卡1(逐行文字退役)与开工检查发现。 + +## 卡1 反哺点(结算期文字列表退役批) + +| # | 文档与位置 | 现行句的问题 | 建议改写方向 | 依据 | +|---|---|---|---|---| +| 1 | design/ux/battle.md:200(B 区组件表「结算明细带」行) | 「结算期逐行流水(9 类行协议);挖掘期收起为上轮摘要 1 行」——逐行流水已退役,现行句描述已不存在的呈现 | 明细带行改写为退役后形态:挖掘期摘要位+叙述行承载;逐行流水行删除或标注已退役(对照 08-Alpha v1.29 删「逐行结算排播」行的口径) | 卡1 §6 同步面(原卡锚 :199 漂移至此);用户裁定 2026-11-07 撤逐行滚动文字列表 | +| 2 | design/ux/battle.md:364(交互矩阵「结算阶段」行) | 「明细带按结算期档展开(高读 log_band_h_settle)逐行滚动」——逐行滚动已退役 | 改写为:明细带 settle 展开保留(空带+叙述行),删除「逐行滚动」表述 | 同上;实现实况=set_mode 保留双态、settle 态零逐行内容 | +| 3 | design/ux/battle.md:439(减动效选项行动效列) | 「动效:结算逐行兑现为信息载体不可整体跳过,可加倍」——「逐行兑现为信息载体」的文字承载面已退役 | 保留「演出不可整体跳过」语义,删除「为信息载体」措辞(FX 排播链仍是兑现呈现,非文字信息载体) | 卡1 R2 判据锚实际指向此行;FX 链退役文字后仍是演出载体 | +| 4 | design/gdd/systems/表现与支撑/HUD框架.md:272 | 「结算逐行实时滚出且每行带来源标注;未生效类行聚合计数并带形状冗余;零值行不被吞」——逐行滚出与聚合计数均已随明细带退役 | 改写为:结算演出链逐行兑现(FX 载体);明细带行删除;「零值行不被吞」语义迁日志面(step 行零删减) | 卡1 R1 退役面;theme_projection 行协议闸改写后的实际形态 | +| 5 | design/gdd/systems/战斗循环/战斗结算.md:87 | 「显示名等玩家可见文案是数据面字段:文案值登记在数据表行,代码按来源类别与效果身份读表拼行」——行协议保留 display_name(呈现面消费),但日志消费端已零文案 | 补一句消费端边界:日志落盘零文案(text/display_name 不落盘),文案仅呈现面消费 | 卡1 T2 落地实况;卡1 §6 已列此文档 | +| 6 | design/gdd/systems/战斗循环/战斗结算.md:80 附近 | 「奖励未生效行」行型的产生面变化:封印作废行(封印格直接挖开)随改判路径退役——未生效行只剩四类(满层/满血/无存活敌人/主角已倒下),且带结构化 reason 键 | 未生效行清单换代(四类+reason 结构化说明);封印作废行除名 | 卡1 T2+卡2 T2(grant_segment:103 作废行块删除) | +| 7 | design/gdd/systems/战斗循环/战斗表现.md:53/:99/:149 | 「结算逐行兑现是信息载体」「能说出这一行来自哪步操作」等句——文字行退役后兑现载体=FX 链 | 三处「逐行兑现」措辞按 FX 载体口径改写 | 卡1 §6 已列 | +| 8 | design/gdd/08-Alpha版本标准.md:42 | 「战斗表现……Alpha 判据=受击闪白/死亡不瞬间消失/高光行/逐行排播四条可读」——「逐行排播」残留(v1.29 只删了「逐行结算排播」独立行,战斗表现条目的判据句漏改) | 判据句删「逐行排播」或改「演出链兑现可读」 | 开工检查发现(v1.29→v1.30 版本漂移核查);与本批裁定直接冲突的残留句 | +| 9 | 体验验证清单.md(:86/:206/:220/:314/:315/:326/:442/:519 八处) | 逐行结算、明细带来源/行序/文案可读性、减半标签等目检项——呈现面已换代 | 逐条按退役后形态改写;新增两条目检项:结算期 B 区零逐行文字、封印受理瞬间揭示观感 | 卡1 §6 已列;卡2 §8 体验面四项(封印受理瞬间揭示、悬停禁字、图例新句、清图高光) | + +## 卡2 反哺点 + +无(设计面前置闭合;实现与排雷玩法.md v2.0 等事实源零冲突)。 + +## 过程发现登记(非文档反哺,供流水线改进参考) + +1. **判据出处行锚漂移**:卡1 复核轮 2 钉死的 ux/battle.md 锚(:199/:272/:347)被 2026-11-07「顶栏搬两侧」文档批修订冲漂(真锚 :200/:364/:439)——写卡段锚钉死后同日文档批仍会冲漂;建议:判据锚钉节锚形态(test-conventions §8.3 已有节锚两档——2026-11-07 架构收窄批立的防漂移机制,写卡段对易漂移文档改用节锚)。 +2. **卡2 §14 预检读数失实**:「两宿主现行公开方法数远低于 20 上限」系凭印象未实跑(minesweeper_rules 恰 20 贴线,+1 即越)——iter-plan 第 3 步预检的 S13 键读数义务应显式包含「新增公开方法的宿主余量实跑核对」。 +3. **流水线闸 ANCHOR_RE 不含中文路径**:待办清单在册条目(2026-11-05 登记),本批卡1 锚全为 ASCII 路径未触雷——非本批引入,留待办。 diff --git a/reports/iter56-实现报告-2026-11-08.md b/reports/iter56-实现报告-2026-11-08.md new file mode 100644 index 00000000..5b0800af --- /dev/null +++ b/reports/iter56-实现报告-2026-11-08.md @@ -0,0 +1,105 @@ +# 迭代 56 实现报告(结算期文字列表退役批+封印动作改版批) + +> 2026-11-08。工作区 `../g-plan2-iter56`(分支 iter56,基线 a0ea828f)。两卡按 roadmap 卡序串行:卡1(结算期文字列表退役)→卡2(封印动作改版)。批性质=代码实现批;`design/`、根规则文件、`game/tests/README.md` 零写入。 + +## 一、各卡状态 + +| 卡 | 状态 | 提交序列 | +|---|---|---| +| 卡1 结算期文字列表退役批 | **完成**(T0-T3 落地;T4 随批末) | f0390714(T1 撤逐行文字)→T2(日志零文案+reason)→T3(分析工具+对拍载体) | +| 卡2 封印动作改版批 | **完成**(T0-T7 落地) | 2d8a4d07(T1 规则核心)→T1a(S13 收敛)→T1b(落位重设计·用户裁)→T2+T5(结算退役+内容退役)→T3(呈现层)→T4(测试探针)→T6(sim 域)→T7(golden) | + +## 二、实际改动—验收对照表(逐行结果读数) + +### 卡1(R1-R5;对照表出处=需求卡片 §10) + +| 需求点 | 验收测试(文件:函数) | 真实入口 | 判据绑定面 | 结果读数 | +|---|---|---|---|---| +| R1 结算期零逐行文字(ux/battle.md:200——原卡锚 :199 漂移,随批维护) | battle_log_zero_text_test.gd:_smoke_settle_zero_rows(新增) | main.tscn 组合根→真实停手→轮末结算→B 区场景树 | 三时点 parsed_text 冻结于进节点叙述基线(step_row 消费零追加——「不绑终屏态」口径) | **绿**(PASS,读数见 f0390714) | +| R2 FX 与 toast 链不断(ux/battle.md:439——原卡锚 :347 漂移) | pacing_queue_test.gd:_assert_fx_chain(新增断言函数) | 真实行集驱动独立 RowPacer | on_step_row_fx 每行一拍+弹道/标出/色环/highlight 三路计数与行型分布逐位一致(期望独立推导);FX 链三函数零 diff=改前逐位一致凭证 | **绿**(PASS) | +| R3 日志零文案、字段不缺(战斗结算.md:87) | playtest_logger_step_fields_test.gd:_assert_no_text_and_full_payload(新增) | LevelAutoplay.run_level 挂 logger→真实结算→落盘 JSONL 读回 | 零 text/display_name+公共七字段与平铺原件逐行在位+damage payload 并集抽样 | **绿**(PASS) | +| R4 分析判读等价(test-conventions.md:133) | tools/analyze_parity_check.py(新增) | 三读数对拍 | ①历史锚改后分析器复跑 vs 改前分析器基线 3394 行逐位一致 ②改前种子日志双版本一致 ③改前 vs 改后同种子(71000)新日志逐位一致 | **绿**(三读数全一致,读数见 T3 提交) | +| R5 未生效原因结构化(战斗结算.md:80) | reward_ineffective_reason_test.gd:_assert_four_reasons(新增) | 真实板身份快照→grant_routed 路由→轮末结账 | 四类 reason 逐一命中(full_hp/hero_down/full_layers=no_guard 堆层/no_enemy_alive=敌 hp=1+blast 双单 defer);两处受控例外直改(hero hp=0、敌 hp=1——无公开 setter,随行申报) | **绿**(PASS,四类全命中) | + +**卡1 测试削弱申报兑现**:§10 点名的改写面全部落地——theme_projection(行协议闸→FX 链逐型闸)、player_copy_sweep(u6 消费半→退役防回潮)、ui_probe:133-135 与 ui_click_probe:423/:473/:616/:798-844(实况核查不依赖退役面——**零改动**,见追认单 #11)、pacing_queue:108-120(断言②改判 FX 承接)。**申报外波及面**(§5.5 同生死):feat_vouchers A12、wr1-b 反转、开场行判定面、ui_input_probe case④b 注记——均改写为退役后正向断言,无删除断言。 + +### 卡2(R1-R12;对照表出处=需求卡片 §8) + +| # | 需求点 | 验收测试 | 真实入口 | 结果读数 | +|---|---|---|---|---| +| R1 | 封印受理即揭示(排雷玩法.md:52) | minesweeper_rules_test:_test_seal_reveal_identity〔封印改 V1〕 | MinesweeperRules.settle_batch() | **绿**(身份逐位+受理零事件+身份与揭示态独立断言) | +| R2 | 挖封印格被拒(排雷玩法.md:56) | _test_dig_sealed_rejected〔V2〕+seal_polarity 四情况组改判〔V2〕 | settle_batch() | **绿**(target_sealed+三态零变化;四情况组标中/标空判定照发) | +| R3 | 清图口径换代(排雷玩法.md:139) | _test_clear_by_seal〔V3〕 | settle_batch() | **绿**(非末封 false/封印计入完成 true/逐格封至清图 round_end 恰一条 cleared) | +| R4 | 能清图的封最优先(排雷玩法.md:167) | _test_classify_seal_clears〔V4〕 | settle_batch() 同批 stop+清图封 | **绿**(赢家=清图封居执行序首、stop voided、round_end cleared) | +| R5 | 判定批与溢价零改动(战斗结算.md:139) | seal_polarity 保留组原样+V5 注记行 | BattleOrchestrator 全轮驱动 | **绿**(76 pass——末点 AP/标中溢价/标空/完美封印双份原样) | +| R6 | 受理零兑付、改判路径死(战斗结算.md:134) | settlement_wiring_snapshot 内嵌 golden〔V6〕 | BattleOrchestrator 真实驱动序 | **绿**——golden 实测零漂移零重录(驱动序不含挖封印格;流水零作废行) | +| R7 | 破封一击退役、词除名(奇物.md:72) | adventure_feats/effect_words_t1 换代〔V7〕+grep | feats.tres 装载路径 | **绿**(grep dug_under_seal/breaker_strike 非测试面=0;词表出册防回潮断言;表 36 件装载绿) | +| R8 | 封印格呈现三件套(ux/battle.md:170) | seal_mark_params:_test_seal_on_revealed_visible〔V8〕+board_shot 选择器+ui_input_probe case④b | 真实场景+push_input | **绿**(新组合态印记可见——受控例外随行申报;探针印记落位绿) | +| R9 | 已封印格零动作(ux/battle.md:299) | ui_input_probe case④c〔V9〕 | push_input 左/右键 | **绿**(零发射+FORBIDDEN+禁入框+图例说明在册) | +| R10 | 文案四件(ux/battle.md:284) | hud_presenter/player_copy_sweep 换代+click 图例〔V10〕 | 文案装配+真实场景 | **绿**(逐字断言换代+雷字清零守恒) | +| R11 | sim 策略对齐+MC 同批重锚(排雷玩法.md:52) | MC 配对读数(测量面) | sim 采集链 master_seed 配对 | 代码面绿(试车 5 局健康);**新锚采集后台进行中,读数回填 §四** | +| R12 | 渲染基线换代(ux/battle.md:170) | 渲染闸+golden 三图重制〔V12〕 | 真实渲染截图 | **绿**(board_shot 双帧取证 PASS+golden 三屏对比全绿;用户预览图人审 2026-11-07 在案;mac 三件缺件记遗留) | + +**卡2 测试削弱申报兑现**:四处点名改判全落地(①四情况组②preview 形态 B③文案逐字④wiring golden——实测零漂移未重录)。**申报外波及面**:adventure_feats 满配种子 42→43+行数 37→36(feats 删行 RNG 流漂移——§5.5 同生死,主区基线对照坐实非行为回归);ui_click_probe 行为②摘除。其余旧断言零删除。 + +## 三、能力行名实核对(game/tests/README.md 批末核对——实现批零写入) + +- :88 封印与极性路由(_smoke_seal_ap_exhausted/_smoke_seal_irreversible_quadrants/_smoke_polarity_routing)——**名实一致**(函数在位) +- :117 挖/封印合法性与可辨拒绝(_test_dig_sealed_rejected/_test_classify_seal_clears 计划锚定)——**落地一致** +- :119 封印受理即揭示(_test_seal_reveal_identity)——**落地一致** +- :120 清图口径换代(_test_clear_by_seal)——**落地一致** +- :121 棋盘封印印记呈现(_test_seal_on_revealed_visible 计划锚定)——**落地一致** +- :137 结算期零逐行文字(_smoke_settle_zero_rows/_assert_fx_chain/standee_fx_sweep_test:_init)——**落地一致** +- :138 日志 step 行零文案(_assert_no_text_and_full_payload/_assert_four_reasons)——**落地一致** +- 改名面:无(零回表) + +## 四、MC 同批重锚读数(R11——已落盘) + +- **规模口径**:50/档=100 局(2026-11-08 用户批准——发起时呈报三档规模供裁,首采 800 局超一档硬闸被停采作废,重采按批准规模执行) +- **新锚**:`runsum_iter56reanchor_81000_n50both_20261108.jsonl`(纯 RUNSUM 100 行、policy+seed 升序;全量明细 jsonl 不留盘) +- **对照读数**(三方对照:改前锚 2026-10-03/改前代码 2026-11-08 现跑 50 局/改后新锚 50 局,同 seed0=81000): + +| 指标 | 改前锚(旧代码读数) | 改前代码现跑 | 改后新锚 | 本批效应 | +|---|---|---|---|---| +| normal 通关率 | 0% | 0% | 0% | 无变化 | +| normal 踩雷均值/局 | 6.72 | 6.48 | 5.40 | -1.08(错触注入替代旧滑失+策略对齐合成) | +| normal 标中/标空/封印数 | 无字段 | 21.34/6.74/52.2 | 19.60/7.86/49.9 | 小幅漂移(标空剔除约束增强+清图口径) | +| perfect 通关率 | 59% | **0%** | **4%** | **改前锚失实早有声明**(sim-data/README:10 代表性注记——两月代码演进使模拟行为漂移;改前代码现跑 0% 对照坐实与本批无关);本批效应方向=升(封印计入清图利好完美策略) | +| perfect 踩雷均值 | 0 | 0 | 0 | 无变化 | +| perfect 标中/封印数 | 无字段 | 45.96/91.1 | 48.28/96.1 | 小幅升(同上方向) | + +- **结构性恶化判定**:无(normal 踩雷降、perfect 通关升——两方向均为改版利好或中性;标中/标空经济小幅漂移在策略对齐预期内) +- **补偿注入验证**:单局实测 st.mine_slips=5 触发(RUNSUM 无 mine_slips 字段——解析口径注记已写入 sim-data/README);注入形态=错触直挖、初值 0.10——**踩雷读数 6.48→5.40 偏差 -1.08/局**——偏差来源含策略对齐合成效应,非纯注入贡献;偏差已记本节与追认单 #5(白名单 5 授权自判) +- sim-data/README.md 现行锚指针已换代+改前锚迁历史锚节(随批合回) + +## 五、实机目检总清单(登记不阻塞——真人目检后销项) + +1. 结算期 B 区零逐行文字(FX 排播链仍逐拍演出) +2. 伤害飘字/弹道/站桩反馈在排播新形态下的观感 +3. 封印受理瞬间揭示的观感(地板+身份字形+法阵叠加——「太忙/太糊」判定归待办「封印格观感细化」条) +4. 封印格悬停禁字与图例新句 +5. 清图高光在封印完成清图时触发 + +## 六、遗留读数义务与遗留面 + +1. MC 新锚采集(后台)→R11 读数回填本报告+sim-data/README 指针换代+新锚 jsonl 携回主区(数据件不入 git——合并后手工拷贝) +2. mac golden 三件缺件(渲染闸 SKIP 不判红)——Mac 环境随批补采或留待含代码面合回的远端 CI +3. 独立验收审计(合并前必做——由未参与实现的子代理执行) + +## 七、实现决定记录 + +见 `reports/iter56-代拍板追认单-2026-11-08.md`(白名单 9 项+实现决定 11 条+目检登记 5 项)。 + +## 八、反哺点 + +见 `reports/iter56-反哺点-2026-11-08.md`(卡1 九条+过程发现三条;卡2 零条)。 + + +## 九、独立验收审计结论与审计后勘误 + +**审计结论:通过**(reports/iter56-验收审计-2026-11-08.md——17 行对照表全量核对,14 干净通过、2 记账级缺漏,无申报外削弱、无同义反复判据;复跑 3 件全绿)。 + +审计两项缺漏的闭环(审计后勘误笔,同批提交): +1. 卡1 R1 函数名:battle_log_zero_text_test.gd 断言体由 _init 内联拆出为 **_smoke_settle_zero_rows**(能力行 :137 点名函数——名实一致补齐;拆分后回归绿)。本报告 §三 R1 行「落地一致」记载在拆分前失实,本段勘误。 +2. 卡2 R6 尾缀载体:〔封印改 V6〕grep 载体缺失(golden 实测零漂移未重录、卡 F9 漏补载体豁免)——settlement_wiring_snapshot_test.gd 宿主 _init 补判据出处注记行(零行为改动,回归绿)。 +3. 观察项随手修:smoke/critical-paths.md 第 7 条「明细逐行呈现」残留旧口径改写;sim-data/README「历史锚」节标题重复去重;T4 提交说明「V5 三函数」实落 2 处(_smoke_seal_irreversible_quadrants 属改判组带 V2 尾缀,不在保留组——措辞勘误)。 \ No newline at end of file diff --git a/reports/iter56-验收审计-2026-11-08.md b/reports/iter56-验收审计-2026-11-08.md new file mode 100644 index 00000000..f258fce1 --- /dev/null +++ b/reports/iter56-验收审计-2026-11-08.md @@ -0,0 +1,89 @@ +# 迭代 56 独立验收审计(结算期文字列表退役批+封印动作改版批) + +> 2026-11-08。审计人=未参与实现的独立子代理;仓库 `D:\code\g-plan2-iter56`(分支 iter56,HEAD=043c24c0,工作区干净);diff 范围 a0ea828f..HEAD(13 个提交,48 文件,+1301/−485)。审计依据=test-conventions §8.7;卡1 验收档=中、卡2 验收档=高(两卡头部申报在案)。全部 grep、diff 与复跑为审计人独立执行,不采信实现报告自述。 + +## 一、执行深度声明 + +| 审计动作 | 卡1(中档) | 卡2(高档) | +|---|---|---| +| 对照表逐行核对 | 全量 5 行(R1-R5) | 全量 12 行(R1-R12) | +| 判据绑定面抽查 | 3 行(R1/R3/R5,超 ≥2 下限) | 6 行(V1/V2/V3/V4/V9/V10,超 ≥5 下限) | +| 测试削弱检查 | 逐处(diff 全部删除行/改判行逐一对申报清单) | 逐处(同左) | +| 直查例外核对 | 逐条 | 逐条 | +| 最窄测试复跑 | 档位规定省略;实际超额跑 2 件(R1/R5,退出码 0×2) | 跑 1 件(minesweeper_rules_test,含 V1-V4,退出码 0) | +| 判据出处抽查 | 3 行(R1/R3/R5 锚句实读) | 6 行(V1/V2/V3/V4/V5/V10 锚句实读对照) | + +## 二、卡1 对照表逐行核对(R1-R5) + +| 行 | 判定 | 依据 | +|---|---|---| +| R1 结算期零逐行文字 | **缺漏(记账级)——测试实质通过** | 测试在位:`game/tests/unit/presentation/battle_log_zero_text_test.gd`(新增)。真实入口实证=main.tscn 实例→`_start_adventure_from_menu`→选节点→真实战斗→`ms.settle_batch` 挖掘/停手→断言 `main.ui.battle.log_view` 组合根链,未绕过改动点(停手经规则批次入口,与生产停手同路径;行流经真实 step_row→row_pacer 信号链)。尾缀〔退役改 V-退役卡 R1〕grep 命中断言 3 处(:77/:81/:85)+run_all 注册行。绑定面=三时点 parsed_text 冻结于进节点叙述基线(非终屏态),复跑 exit 0。**缺漏本体**:卡 §10 与 game/tests/README.md:137 点名函数 `_smoke_settle_zero_rows` 全仓不存在(断言内联在 `_init`)——白名单 1 授权命名自判,但 §8.9「改名则回表」未履行(能力清单仍载旧名),且实现报告 §三 记「落地一致」失实 | +| R2 FX 与 toast 链不断 | 通过(附注记) | `pacing_queue_test.gd:_assert_fx_chain`(:190)在位,尾缀〔退役改 V-退役卡 R2〕命中 8 处断言;真实行集=main.tscn 真实结算采样 step_row,重放独立 RowPacer(生产类),弹道/标出/色环/横幅/toast 计数从行型分布独立推导(防同义反复形态);`standee_fx_sweep_test.gd:_init` 在位未改。附注:卡绑定面写「与改前逐位一致」,落地改为「期望独立推导逐位一致」+「FX 链三函数零 diff」(f0390714 可证)——防镜像性更强,判等价兑现 | +| R3 日志零文案、字段不缺 | 通过 | `playtest_logger_step_fields_test.gd:_assert_no_text_and_full_payload`(:37)在位;真实入口=LevelAutoplay.run_level 挂 logger→落盘 JSONL 读回(:41-48),非直调 logger。尾缀 R3 命中断言 4 处。改动面核对:playtest_logger.gd 删 text 键+平铺跳 display_name,其余字段与 payload 一行未减(diff 实证) | +| R4 分析判读等价 | 通过(读数为提交记载,未独立复采) | `tools/analyze_parity_check.py` 在位(剥名逐位 diff+exit 语义完整);analyze_playtest.py 三处 text 判读改结构化 reason 优先+text 子串兜底双分支(diff 实证,effect_id_of 既有双分支未动)。T0 两份基线快照在盘:`sim-data/改前判读基线_历史锚71000_20261107.md`(3394 行)、`改前判读基线_同种子71000_20261107.md`(149 行),与报告读数行数吻合;三读数一致记载于提交 82725501。改后同种子日志未留盘,审计未复采(复采属读数类采集,不在审计义务内)——以工具+基线+提交读数为凭 | +| R5 未生效原因结构化 | 通过 | `reward_ineffective_reason_test.gd:_assert_four_reasons`(:63)在位;真实入口=begin_battle→grant_routed→轮末结账,四类 reason 逐一命中,复跑 exit 0(12 checks)。六发射点 reason 键全部落地(damage_controller/reward_grant×3/combat_segment/grant_segment,diff 实证)。尾缀 R5 的 grep 载体=run_all.gd:216 注册行;套件内断言消息未带尾缀(卡1 各套件落点不统一,注记不判缺) | + +**卡1 削弱检查(逐处)**:diff 全部删除/改判行共 8 处,逐一对申报——①pacing_queue「末信号行原文在明细带」→卡 §10 申报面(:108-120)改判 FX 承接 ✓;②theme_projection 9 类格式化闸→卡 §10 申报(_smoke_battle_log_view)改写 FX 落拍闸 ✓;③player_copy_sweep u6→卡 §10 申报(_sweep_source_names)改防回潮 ✓;④feat_vouchers A12→申报外,§5.5 同生死登记(追认单 #3)✓;⑤ui_click_probe wr1-b 反转+开场行判定面→追认单 #6 ✓;⑥theme/player_copy/ui_probe:133-135 零改动→git diff 实证 ui_probe.gd 空 diff,追认单 #11 属实 ✓;⑦run_all.gd 只增不删 ✓。**未发现申报外削弱**。 + +**卡1 直查例外(逐条)**:①reward_ineffective_reason_test hero hp=0/敌 hp=1 直改——测试头注 :7 随行申报「无公开 setter」,报告 R5 行同记 ✓;②该件 `settlement._reward_economy`/`_enemy_system` 私有域直查——旧版 seal_polarity_test 已有 4 处同款(git show a0ea828f 实证),头注注记「seal_polarity 负向直验同款形态」,按既有先例豁免;③pacing_queue/theme_projection 的 FakeStage/FakeFx/FakeBoardZone 为测试替身记参件,非产品内字段直查,不属例外。 + +## 三、卡2 对照表逐行核对(R1-R12) + +| 行 | 判定 | 依据 | +|---|---|---| +| R1 封印受理即揭示 | 通过 | `minesweeper_rules_test.gd:_test_seal_reveal_identity`(:201)在位;**真实入口=settle_batch**(:209/:225 提交 seal_place,未直调 `_do_seal_place`);身份断言=has_seal+`BoardData.cell_identity` 组合(T1b 落位重设计后形态,公开面);受理零 cell_revealed 哨兵在位;尾缀 V1 命中 4 断言。复跑 exit 0 | +| R2 挖封印格被拒 | 通过 | `_test_dig_sealed_rejected`(:234)在位:sub_reason=="target_sealed"+封印/揭示/AP 三态零变化,经 settle_batch;seal_polarity 四情况组 ②④改判为拒绝断言(diff 实证,见削弱①);V2 尾缀命中 minesweeper_rules_test 2 处+seal_polarity 8 处 | +| R3 清图口径换代 | 通过 | `_test_clear_by_seal`(:255)在位:非末封 false→唯一未完成格=已封印 true→干净板逐格封至清图、round_end 恰一条 path_tag=cleared;全部经 settle_batch。V3 尾缀命中 5 断言 | +| R4 能清图的封最优先 | 通过 | `_test_classify_seal_clears`(:292)在位:同批 stop+清图封,赢家=清图封居执行序首、stop voided、round_end=cleared;经 settle_batch。V4 尾缀命中 4 断言 | +| R5 判定批与溢价零改动 | 通过 | seal_polarity 保留组(末点 AP/标中溢价/标空负向/完美封印双份)断言原样未改(diff 实证删改仅限四情况 ②④改判面);V5 判据出处注记 grep 命中 2 处(:51/:444)——T4 提交说明称「三函数」,实落 2 处(注记级出入,两保留组函数均有载体,不判缺) | +| R6 受理零兑付、改判路径死 | **缺漏(载体级)——实质通过** | 实质面:settlement_wiring_snapshot_test.gd 零改动(git diff 空),内嵌 golden 在「挖+封+停」驱动序下绿(提交 92086027 四套件绿含 wiring_snapshot)——golden 未漂移=改判路径零触发实证;grant_segment :102-104 作废行块删净+流水零作废行(代码 diff 实证)。**缺漏本体**:〔封印改 V6〕尾缀全仓(game/+tools/)grep 零命中——卡原定载体=golden 重录,实测零漂移未重录后载体消失;卡 F9 只为 V11/V12 补了「报告+提交说明」载体豁免,V6 漏补同类豁免 | +| R7 破封一击退役、词除名 | 通过 | feats.tres 删行(diff 实证,feat_id=breaker_strike 整块);effect_engine 词表删 dug_under_seal(diff 实证);effect_words_t1 新增出册防回潮断言+adventure_feats V7 注记(:722);grep dug_under_seal/breaker_strike 非测试面=0(审计独立 grep 实证)。注记:effect_words_t1 断言尾缀用卡1 家族「退役改 V7」而非「封印改 V7」——V7 载体在 adventure_feats 在位,家族混用不判缺 | +| R8 封印格呈现三件套 | 通过(附注记) | `seal_mark_params_test.gd:_test_seal_on_revealed_visible`(:62)在位:seal_on 与揭示同真印记仍可见(新组合态);board_shot_probe 选择器换代(:145);ui_input_probe case④b 印记落位断言在位。**附注**:卡探针半句「身份字形可查」在 case④b 无显式断言载体——身份字形可观测面由 golden 三图(含字形像素,R12)+V1 身份查询断言+board_view 读 cell_identity 代码(diff 实证)合围,无旧断言被删,不算削弱。直查例外:`u._seal_layer` 直读随行申报(测试注记+卡 §8 R8 行+复核 F12)✓ | +| R9 已封印格零动作 | 通过 | ui_input_probe case④c(:288-312 新增):真输入左/右键已封印格 action_requested 零发射+CURSOR_FORBIDDEN+封印禁入框+图例说明在册含「受理即揭示」;V9 尾缀命中 5 断言;判据出处 ux/battle.md 已封印格行(Interaction Map)实读吻合 | +| R10 文案四件 | 通过 | hud_presenter_test 文案逐字换代(seal_present 新句+target_sealed 新句,diff 实测与 hud_presenter.gd 逐字一致);player_copy_sweep u4 图例断言换代(含零雷字守恒);ui_click_probe 图例行断言换代(:1218,V10 尾缀在位)。措辞按白名单 2 授权 | +| R11 sim 策略对齐+MC 同批重锚 | 通过(载体=报告+提交,卡 F9 豁免在案) | auto_player._constraints 封印身份增强(标中计已知雷/标空剔除,读 board.cell_identity 公开面);level_autoplay 标雷误挖退役→错触直挖 NORMAL_MISDIG_P=0.10(白名单 5+用户裁③);新锚 `sim-data/runsum_iter56reanchor_81000_n50both_20261108.jsonl` 在盘 100 行(审计实证);sim-data/README 现行锚指针换代+改前锚迁历史节+59% 失实注记(diff 实证);三方对照读数与偏差 -1.08 记报告 §四+追认单 #5+提交 043c24c0;规模 50/档=2026-11-08 用户批准在案 | +| R12 渲染基线换代 | 通过(载体=报告+提交,卡 F9 豁免在案) | golden 三图 windows 三件换代(diff stat 二进制变更实证);提交 91882776 记 board_shot 双帧取证 PASS+渲染闸三屏绿;用户预览图人审 2026-11-07 在案;mac 三件缺件记遗留(卡 T7 预案:渲染闸 SKIP 不判红)。渲染闸未由审计复跑(昂贵件,以提交读数+基线字节换代为凭) | + +**卡2 削弱检查(逐处)**:diff 删除/改判行共 11 处——①seal_polarity 四情况「直挖受理/隐式退役/零兑付作废」→卡 §8 削弱① ✓(改判后正向断言钉新语义:target_sealed+判定照发);②minesweeper_rules preview 形态 B→卡 §8 削弱② ✓(改判「preview 封印格零外扩」);③hud_presenter/player_copy_sweep 文案逐字→卡 §8 削弱③ ✓;④settlement_wiring golden→卡 §8 削弱④,实测零漂移未重录(追认单 #9),零削弱 ✓;⑤adventure_feats 破封一击断言删→卡 §8 R7 行明示 ✓;⑥种子 42→43+表行 37→36/优秀 13→12 判值换代→申报外 §5.5 同生死(追认单 #7,主区基线 42 绿对照坐实)✓;⑦effect_words 词表断言换代→卡 §8 R7 ✓;⑧board_shot 选择器放宽(has_seal 单条件)→取证选择器非断言放宽,卡 §8 R8 申报 ✓;⑨ui_click_probe 行为②摘除+恢复现场死调用删+簿记调整→卡 §7/§8 R9+追认单 #8 ✓;⑩ui_input_probe case④b 判据出处注记换代、断言体保留 ✓;⑪smoke/critical-paths.md 第 4 条改写→卡 §5/T4 申报 ✓。**未发现申报外削弱**。 + +**卡2 直查例外(逐条)**:①seal_mark_params `u._seal_layer` 直读——随行申报 ✓(见 R8 行);②minesweeper_rules_test `_ae._phase=1` 直置与 `_ae._ap` 直读——测试 :34-35 注记「与既有单测直读 action_economy._ap 先例同款」在案;`_ae._ap` 有公开读数(ap)可用,直读属非必要但注记在案、只读断言面,不判缺;③`_rules._safe_remaining()` 私有读——旧版同款既有面;④`_rules.dig_range_bonus=99`/`seal_range_free=true`——公开属性,注记在案;⑤`_board.cell_identity`——公开方法(T1b 后),非例外;⑥cells_probe()/legend ENTRIES——探针公开取证面。 + +## 四、判据出处抽查(卡2 6 行+卡1 3 行,规格句实读对照) + +| 行 | 锚 | 独立可写性判定 | +|---|---|---| +| V1 | 排雷玩法.md:52 | 锚句「封印被受理的那一刻……格下身份当场可见……受理当场零兑付、零事件」——kind/digit 断言与零 cell_revealed 均可独立写出 ✓ | +| V2 | 排雷玩法.md:56 | 合法性表「挖|8 向相邻、未开、无封印|……已封印」+「拒绝原因独立可辨」——target_sealed+三态零变化可独立写出 ✓ | +| V3 | 排雷玩法.md:139 | 「清图 = 全部安全格完成,完成 = 挖开或封印——封印可以完成清图」——两阶段断言可独立写出 ✓ | +| V4 | 排雷玩法.md:167 | 批次语义序表「最先|能清图的挖或封——防止耗尽优先把清图高光结算成最差结局」——赢家断言可独立写出 ✓ | +| V5 | 战斗结算.md:139 | 判定批兑付表「标中则兑正向……标空则记归因行+负向」——保留组预期可独立写出 ✓ | +| V10 | ux/battle.md:284 | 拒绝细分与 toast 要求实读吻合;字句按白名单 2 授权润色,语义四件(受理即揭示/不可再挖开/随图存续/零动作)全部溯至排雷玩法 :52/:56 ✓ | +| 卡1 R1/R3/R5 | 08-Alpha:154/战斗结算:87/:80 | 零逐行文字、日志零文案全字段、四类合法例外 reason——预期值均可自锚句独立写出,非实现镜像 ✓ | + +抽查结论:未发现「预期值只在实现里有的」同义反复断言。R2 的期望计数从行型分布独立推导(非抄实现),形态更优。 + +## 五、最窄测试复跑读数(审计独立执行) + +| 测试 | 覆盖 | 结果 | +|---|---|---| +| `res://tests/unit/core/minesweeper_rules_test.gd` | 卡2 V1-V4(经 settle_batch) | **exit 0**(all green,36 checks;V1-V4 断言全 PASS) | +| `res://tests/unit/battle_settlement/reward_ineffective_reason_test.gd` | 卡1 R5 | **exit 0**(all green,12 checks;四类 reason 全命中) | +| `res://tests/unit/presentation/battle_log_zero_text_test.gd` | 卡1 R1 | **exit 0**(该测试 _problems 非空才 quit(1),exit 0 即全绿) | + +环境:Godot_v4.7.2-stable_win64_console.exe --headless,工作目录 `D:\code\g-plan2-iter56\game`。只跑单件,未跑全套。 + +## 六、缺漏汇总与整改义务 + +| # | 缺漏 | 级别 | 整改义务与去向 | +|---|---|---|---| +| 1 | 卡1 R1 点名函数 `_smoke_settle_zero_rows` 未落(断言内联 `_init`);game/tests/README.md:137 仍载旧名;实现报告 §三「落地一致」记载失实;改名未回表 | 记账级(测试实质完整:真实入口/尾缀/绑定面/复跑全过) | 回表改 capability 行函数名=写卡段通道(实现批禁写 README,无法合并前自行修);实现报告 §三 勘误=实现会话合并前可改(reports/ 面合法) | +| 2 | 卡2 R6〔封印改 V6〕尾缀全仓无 grep 载体(golden 零漂移未重录,原定载体消失;F9 豁免只补了 V11/V12) | 载体级(实质由未改动绿 golden+grant_segment 删净承接) | 补一处载体注记(settlement_wiring_snapshot_test 或实现报告 R6 行明示载体改报告+提交说明)——随追认单或文档批处理 | +| 3 | 观察项(不判缺漏):T4 提交说明称 V5「三函数」实落 2 处;effect_words_t1 尾缀家族用「退役改 V7」;ui_click_probe `_play_one_action` 残留一处不可达 return(:1390 附近,死代码,解析无碍——探针 exit 0 实证可跑);reward_ineffective_reason_test 断言消息未带 R5 尾缀(载体在 run_all 注册行);smoke/critical-paths.md 第 7 条「明细逐行呈现」残留旧口径(卡1 未申报面,建议列反哺点);sim-data/README.md「历史锚(只读不判)」节标题重复两处(:12/:17,编辑残留) | 轻微 | 随下批顺手清或文档批收口 | + +## 七、结论 + +**通过**(附上表 2 项记账级缺漏——均不触验收实质:无申报外削弱、无同义反复判据、无真实入口绕过、无直查例外未申报;缺漏 1 的回表通道归写卡段、缺漏 2 归载体注记补登,均不构成合并阻断)。 + +依据:17 行对照表全量核对,14 行干净通过、2 行缺漏(R1卡1/R6卡2,实质面均由独立证据承接)、1 行附注(R8 探针半句由 golden+V1 合围);削弱检查逐处零申报外删改;直查例外逐条有申报或既有先例;判据出处 9 行抽查全部可独立写出;最窄测试 3 件复跑全绿(exit 0×3)。卡1 中档四件全做+复跑超额执行;卡2 高档五件全做。 + +审计人注:本报告为合并前独立审计(test-conventions §8.7);卡1 中档的远端 CI 现查读数代证义务在合回后由 `tools/ci_check.sh <合并提交>` 兑现,本报告复跑读数已超额覆盖该档复跑面。 diff --git a/sim-data/README.md b/sim-data/README.md index 7c92bbde..1d2a89ac 100644 --- a/sim-data/README.md +++ b/sim-data/README.md @@ -4,13 +4,17 @@ ## 现行锚(分析取数唯一入口) -- 锚:`runsum_m3iter9exit_81000_n400_20261003.jsonl`(N=400/档=普通 v3.4+perfect;种子 81000 起逐局独立;route=min;taken=hero_dmg+mine_dmg,口径对齐真人分析端) -- 读数:`读数_m3iter9exit81000_20261003.md`(k=0.60、完美战役全通 59.0%、结构位移存档) -- 换代义务:M-4 数值批投值时本锚随批作废,批内自取改前/改后锚 -- 代表性注记:0 格连锁算子补齐改变模拟玩家行为(推理面增强),perfect/adv 档通关率预期上调——本锚读数对现行代码代表性下降,带值结论须注明「旧行为基线」口径;重跑归一档(用户发起),发起时批内自取改前/改后锚 +- 锚:`runsum_iter56reanchor_81000_n50both_20261108.jsonl`(N=50/档=普通+perfect;种子 81000 起逐局独立;route=min;taken=hero_dmg+mine_dmg,口径对齐真人分析端) +- 规模口径:**50/档(2026-11-08 用户批准)**——95% CI 约 ±14pp(p≈0.5 处),结构级位移(≥25pp)可判;更细精度需下一次合法触发(如数值标定窗口)时由用户加采 +- 换代缘由:封印受理即揭示改版(迭代 56——挖封印格被拒/清图口径=挖开∪封印/sim 策略对齐)改变玩法行为,改前锚失效 +- 改前改后对照读数(迭代 56 R11——改前代码 2026-11-08 现跑 50 局 vs 新锚 50 局,同 seed0):normal 通关 0%→0%、踩雷均值 6.48→5.40、标中 21.34→19.60、标空 6.74→7.86、封印数 52.2→49.9;perfect 通关 0%→4%(封印计入清图的利好方向)、标中 45.96→48.28、封印数 91.1→96.1——无结构性恶化;错触直挖注入单局实测触发(st.mine_slips=5;RUNSUM 无此字段,解析口径注记) ## 历史锚(只读不判) +- `runsum_m3iter9exit_81000_n400_20261003.jsonl`(迭代 56 前任现行锚,N=400/档)——**其 perfect 通关 59% 系 2026-10-03 旧代码读数**:0 格连锁算子补齐等两月演进已使模拟行为漂移,改前代码 2026-11-08 现跑 50 局 perfect 通关 0%——带值引用须注明「旧行为基线」口径 +- 其余 23 份 `runsum_*.jsonl`,文件名=批+锚名+日期可辨。出度≤2 重建后旧锚结构已失义,仅留换代对照;处置挂中间产物死亡清单随批处理。 + + 其余 23 份 `runsum_*.jsonl`,文件名=批+锚名+日期可辨。出度≤2 重建后旧锚结构已失义,仅留换代对照;处置挂中间产物死亡清单随批处理。 ## MC 采集档位表(2026-10-03 用户四档立规——按用途定局数上限,禁惯例继承禁计划书自拟) diff --git a/tools/analyze_parity_check.py b/tools/analyze_parity_check.py new file mode 100644 index 00000000..e4946559 --- /dev/null +++ b/tools/analyze_parity_check.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""判读等价对拍工具(结算期文字列表退役批 R4 载体——日志零文案改后分析判读逐位一致)。 + +用法: + python tools/analyze_parity_check.py <输入A> <输入B> [<输入C> ...] [--runs N] + +输入 = 日志(.jsonl,各跑一次 tools/analyze_playtest.py 完整判读)或基线快照 +(.md,analyze_playtest.py 输出重定向落盘的静态文本,直读不重跑)。全部剥去文件名 +标题行后相邻逐位 diff——任一对不一致即 exit 1 并打印第一条分歧行。 + +三读数跑法(改前改后配对,卡 R4): + ① 历史锚日志 vs 改前 T0 基线快照(兼容分支等价) + ② 改前同种子日志 vs 改前 T0 基线快照(复现等价) + ③ 改前同种子日志 vs 改后同种子新日志(同种子+玩法零改动=事件流逐位同) + +退出码: 0=全部逐位一致 1=判读分歧 2=参数/执行错误 +""" +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ANALYZE = HERE / "analyze_playtest.py" + + +def run_analyze(log_path: str, runs: str) -> str: + cmd = [sys.executable, str(ANALYZE), log_path] + if runs: + cmd += [runs] + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8") + if proc.returncode != 0: + print("[parity] 判读执行失败(%s)exit=%d" % (log_path, proc.returncode)) + print(proc.stdout[-2000:]) + print(proc.stderr[-2000:]) + sys.exit(2) + # 剥文件名标题行(playtest_*.jsonl 文件名随输入变,不参与逐位比对) + return strip_name_lines(proc.stdout) + + +def strip_name_lines(text: str) -> str: + out_lines = [] + for ln in text.splitlines(): + if "playtest_" in ln and ".jsonl" in ln: + continue + out_lines.append(ln) + return "\n".join(out_lines) + + +def first_diff(a: str, b: str): + la, lb = a.splitlines(), b.splitlines() + for i in range(max(len(la), len(lb))): + xa = la[i] if i < len(la) else "<缺行>" + xb = lb[i] if i < len(lb) else "<缺行>" + if xa != xb: + return i + 1, xa, xb + return None + + +def main(): + args = sys.argv[1:] + runs = "" + if "--runs" in args: + i = args.index("--runs") + runs = args[i + 1] + args = args[:i] + args[i + 2:] + if len(args) < 2: + print(__doc__) + sys.exit(2) + outputs = [] + for p in args: + if p.lower().endswith(".jsonl"): + outputs.append(run_analyze(p, runs)) + else: # 基线快照(.md)——静态文本直读,剥名规则同判读输出 + outputs.append(strip_name_lines(Path(p).read_text(encoding="utf-8"))) + ok = True + for i in range(1, len(outputs)): + d = first_diff(outputs[i - 1], outputs[i]) + pair = "%s <-> %s" % (Path(args[i - 1]).name, Path(args[i]).name) + if d is None: + print("[parity] 一致 | %s | 判读逐位一致" % pair) + else: + ln, xa, xb = d + print("[parity] 分歧 | %s | 第 %d 行" % (pair, ln)) + print(" A: %s" % xa) + print(" B: %s" % xb) + ok = False + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/analyze_playtest.py b/tools/analyze_playtest.py index 524c8e47..72ba188c 100644 --- a/tools/analyze_playtest.py +++ b/tools/analyze_playtest.py @@ -186,7 +186,7 @@ def analyze_run_core(rows, meta): if cur_b is not None: cur_b["hero_dmg"] += v elif src in ("safe_cell", "clear_reward", "flag_hit") or ( - src == "mine_hit" and "全体敌人" in r.get("text", "")): + src == "mine_hit" and r.get("target") == "all_enemies"): out["reward_dmg"] += v out["rewards_n"] += 1 name = "清障炸药" if src == "clear_reward" else "群伤" @@ -431,7 +431,7 @@ def analyze(rows, label): cf_n = int(round(n * 100.0 / (100 + up))) rowout["cf_hero"] += cf_n elif src in ("safe_cell", "clear_reward", "flag_hit") or ( - src == "mine_hit" and "全体敌人" in s.get("text", "")): + src == "mine_hit" and s.get("target") == "all_enemies"): # 奖励伤害 = 打敌人的棋盘来源伤害;踩雷负向打主角的(反噬震荡)归承伤侧 rowout["rw"] += int(s["value"]) rowout["pos_n"] += 1 @@ -468,17 +468,32 @@ def analyze(rows, label): rowout["cf_tr"] += cf_norw for s in steps: if s["type"] == "reward_ineffective": - txt = s.get("text", "") - if "满层" in txt: + # 结构化 reason 优先(日志零文案批——四类枚举 full_layers/full_hp/ + # no_enemy_alive/hero_down);text 子串兜底 = 旧日志兼容分支(改前日志 + # 无 reason 键——判读等价两腿各走各分支,结论逐位一致) + reason = s.get("reason", "") + if reason == "full_layers": key = "增益满层" - elif "满血" in txt: + elif reason == "full_hp": key = "回血满血" - elif "无存活敌人" in txt: + elif reason == "no_enemy_alive": key = "无存活敌人" - elif "主角已倒下" in txt: + elif reason == "hero_down": key = "主角已倒下" - else: + elif reason: key = "其他" + else: + txt = s.get("text", "") + if "满层" in txt: + key = "增益满层" + elif "满血" in txt: + key = "回血满血" + elif "无存活敌人" in txt: + key = "无存活敌人" + elif "主角已倒下" in txt: + key = "主角已倒下" + else: + key = "其他" rowout["ineff_counts"][key] = rowout["ineff_counts"].get(key, 0) + 1 bstat["hero_dmg"] += rowout["hero"] bstat["reward_dmg"] += rowout["rw"]