# VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2) # Date: 2026-09-10 # 排雷玩法规则引擎:零随机/零表读取/零 UI——输入 = 棋盘生成 产物 + 意图序列,输出 = 三事件流。 # 核心契约:install 四不变式 · 初始展开 = R₀ 恰等 · 连锁不越旗 · 批次语义序仲裁(cleared > stop > 其余)。 # 旗语义(插旗不可逆批 2026-09-19 用户拍板全套落地):旗 = 一次性承诺——挖旗格放行(本格旗事务内 # 隐式退役、轮末不判),拔旗动作退役(flag_pull 出目录)。 # 实现 GDD:design/archive/gdd-2026-09-14/a2-minesweeper-rules.md(系统 ID A2·排雷玩法——registry 追溯锚,命名规范见 CLAUDE.md) class_name MinesweeperRules extends RefCounted signal cell_revealed(evt: Dictionary) signal flag_judged(evt: Dictionary) signal round_end_board(payload: Dictionary) # 双发射源之棋盘路径(排雷玩法 外发;stopped 由 行动经济 直发) const SUB_REASONS := ["not_adjacent", "target_revealed", "target_concealed", "flag_present", "no_path", "out_of_catalog"] # flag_absent 随 flag_pull 退役(2026-09-19) var db var action_economy # 棋盘状态私有(E2 封装收口 2026-09-13):对外只暴露 current_board()/reveal_record() 等查询面, # 禁止跨模块直读/直写原始字典——先例教训 = board_view 绕过查询面直读 revealed。 var _board: BoardData = null var _revealed := {} # Vector2i → {"kind": "safe"/"mine", "digit": int} var _flags := {} # Vector2i → true(棋盘单态;跨轮保留随图存续) var _hero := Vector2i.ZERO var _spawn: Dictionary = {} func _init(db_ref, action_economy_ref) -> void: db = db_ref action_economy = action_economy_ref # ---- 板态 ---- func has_board() -> bool: return _board != null # 板产物只读出口(E2 收口——战斗结算 快照 / 组合根 rebuild / 探针经此取板,禁再直读字段) func current_board() -> BoardData: return _board # 已开格记录查询面(cell → {kind, digit};未开 = {})——呈现层渲染消费面(board_view 徽章/四格态) func reveal_record(c: Vector2i) -> Dictionary: return _revealed.get(c, {}) func hero_pos() -> Vector2i: return _hero func is_revealed(c: Vector2i) -> bool: return _revealed.has(c) func has_flag(c: Vector2i) -> bool: return _flags.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): n += 1 return n func clear_map_cleared() -> bool: return safe_remaining() == 0 # ---- install(编排边界相位 = 行动经济 Idle/Waiting;Active 拒绝——防轮内换板)---- # during_round = 轮内刷新编排路径(2026-09-12 方案 A,战斗结算 唯一调用方):Active 相位合法—— # 玩家挖掘中立即换图,棋盘状态(revealed/flags)随 install 重置,初始展开事件照发(initial 通道) func install_board(b: BoardData, during_round := false) -> Dictionary: if action_economy.phase == action_economy.PHASE_ACTIVE and not during_round: return _outcome(false, "rejected", "phase", "") if action_economy.phase == action_economy.PHASE_IDLE and during_round: return _outcome(false, "rejected", "phase", "") if b == null: return _outcome(false, "rejected", "board_invalid", "out_of_catalog") var inv := _install_invariant_faults(b) if not inv.is_empty(): push_error("[排雷玩法] install 不变式违例: %s" % str(inv)) return _outcome(false, "rejected", "board_invalid", inv[0]) _board = b _revealed.clear() _flags.clear() _spawn = {} for c in b.spawn_cells(): _spawn[c] = true # 初始展开:O₀ = R₀ 恰等(免费/计入清图/无格面资源/永不触雷);板级一次跨轮不重放 for c in _initial_expansion_order(b): _revealed[c] = {"kind": "safe", "digit": b.digit_of(c)} cell_revealed.emit({ "cell": c, "kind": "safe", "digit": b.digit_of(c), "variant_ids": [], "channel": "initial", "resource": false, }) _hero = b.spawn_center return _outcome(true, "executed", "", "") # during_round = 轮内刷新编排路径(2026-09-12 方案 A):Active 相位合法——换图前退役旧板 func flag_count() -> int: return _flags.size() func retire_board(during_round := false) -> Dictionary: if _board == null: return _outcome(true, "executed", "", "") # 幂等受理 if action_economy.phase == action_economy.PHASE_ACTIVE and not during_round: return _outcome(false, "rejected", "phase", "") _board = null _revealed.clear() _flags.clear() return _outcome(true, "executed", "", "") # 四不变式(诊断类别封闭枚举):inv1 出生区雷 / inv2a 出生区变异 / inv2b R₀ 变异 / inv3 数字恒真 / inv4 R₀=S func _install_invariant_faults(b: BoardData) -> Array: var faults: Array = [] var r0 := {} for c in b.compute_r0(): r0[c] = true for c in b.spawn_cells(): if b.is_mine(c): faults.append("inv1_mine_in_spawn") if b.variant_ids(c).size() > 0: faults.append("inv2a_variant_in_spawn") for c in r0.keys(): if b.variant_ids(c).size() > 0: faults.append("inv2b_variant_in_r0") for c in b.all_cells(): if b.is_safe(c): var d := 0 for n in b.neighbors8(c): if b.is_mine(n): d += 1 if b.digit_of(c) != d: faults.append("inv3_digit_invariant") if r0.size() == b.safe_cells().size(): faults.append("inv4_r0_equals_s") return faults # 初始展开规范序:触发格 BFS、8 邻行主序(≡ 棋盘生成 F1 R₀ 算子;禁外扩环、禁只开 3×3) func _initial_expansion_order(b: BoardData) -> Array: var order: Array = b.spawn_cells().duplicate() var seen := {} for c in order: seen[c] = true var queue: Array = order.duplicate() while not queue.is_empty(): var c: Vector2i = queue.pop_front() if b.is_safe(c) and b.digit_of(c) == 0: for n in b.neighbors8(c): if b.is_safe(n) and not seen.has(n): seen[n] = true order.append(n) queue.append(n) return order # ---- 批次结算(唯一提交入口;单意图批 = 平凡形 ≡ 便捷 API)---- func settle_batch(intents: Array) -> Array: var outcomes: Array = [] if intents.is_empty(): return outcomes if action_economy.phase != action_economy.PHASE_ACTIVE or _board == null: for intent in intents: outcomes.append(_outcome(false, "rejected", "phase", "")) return outcomes # F4 语义序仲裁:C_dig(真模拟清图)→ C_stop → C_other;类内稳定序 = 到达序 var indexed: Array = [] for i in intents.size(): indexed.append({"i": i, "intent": intents[i], "cls": _classify(intents[i])}) indexed.sort_custom(func(a, b): if a.cls != b.cls: return a.cls < b.cls return a.i < b.i) var round_over := false for item in indexed: if round_over: outcomes.append(_outcome(false, "voided", "", "")) continue var res := _execute(item.intent) outcomes.append(res) if action_economy.phase != action_economy.PHASE_ACTIVE: round_over = true return outcomes 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_flags: Dictionary = _flags.duplicate() sim_flags.erase(c) var chain := _chain_closure(c, sim_flags) # 与真实执行同口径——空旗集捷径会在旗挡连锁时误判 C_dig(排雷玩法:真模拟) var remaining := 0 for sc in _board.safe_cells(): if not _revealed.has(sc) and not chain.has(sc): remaining += 1 if remaining == 0: return 0 return 2 if intent.get("action", "") == "stop": return 1 return 2 func _execute(intent: Dictionary) -> Dictionary: match intent.get("action", ""): "dig": return _do_dig(intent.get("cell", Vector2i(-9, -9))) "flag_place": return _do_flag_place(intent.get("cell", Vector2i(-9, -9))) "move": return _do_move(intent.get("cell", Vector2i(-9, -9))) "stop": return _do_stop() push_warning("[排雷玩法] 目录外意图: %s" % str(intent)) return _outcome(false, "rejected", "board_face", "out_of_catalog") # ---- 挖掘 ---- func _do_dig(c: Vector2i) -> Dictionary: var sub := _board_face_dig(c) if sub != "": return _outcome(false, "rejected", "board_face", sub) # 挖旗格(插旗不可逆批 2026-09-19):本格旗事务内隐式退役——链闭包按「本格无旗、其余旗照挡」 # 计算(旗格为 0 时连锁照常外扩);request_dig 被拒则旗原地保留(事务性),受理后该旗轮末不判 # (发射面基线 = 在场旗——标中/标空兑付让位于挖掘本体结局,四情况②④)。 var was_flagged := has_flag(c) var eff_flags: Dictionary = _flags if was_flagged: eff_flags = _flags.duplicate() eff_flags.erase(c) # 真模拟连锁与清图(F2/F1——单一实现两名字) var chain := _chain_closure(c, eff_flags) var clears: bool = _cleared_after(chain) # F2 口径:chain_size = |O′ ∖ (O ∪ {c})|——闭包含已开集 O,只数新开格(不含首格) var chain_size := 0 for cc in chain.keys(): if cc != c and not _revealed.has(cc): chain_size += 1 # 资源面/相位 + cleared 仲裁在 行动经济 事务内原子完成 if not action_economy.request_dig(chain_size, clears): return _outcome(false, "rejected", "resource_face", "") # 事务内执行:首格 + 连锁新格揭示(BFS 规范序,零特判);事件 = O′ ∖ O if was_flagged: _flags.erase(c) var reveal_order: Array = _chain_reveal_order(c, eff_flags) for rc in reveal_order: if _revealed.has(rc): continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|) var is_mine := _board.is_mine(rc) var channel := "dig" if rc == c else "chain" # 格面资源资格(2026-09-15 格面资源批):chain/dig 恒 true——安全格与雷格一视同仁(雷格亦携带, # 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。 # dug_under_flag = 首格带旗直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越旗) if is_mine: _revealed[rc] = {"kind": "mine", "digit": -1} cell_revealed.emit({ "cell": rc, "kind": "mine", "digit": -1, "variant_ids": [], "channel": channel, "resource": true, "dug_under_flag": was_flagged 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_flag": was_flagged and rc == c, }) if action_economy.phase != action_economy.PHASE_ACTIVE: _emit_round_end_flags_and_payload() return _outcome(true, "executed", "", "") func _board_face_dig(c: Vector2i) -> String: if not _board.in_bounds(c): return "out_of_catalog" if not _adjacent_to_hero(c): return "not_adjacent" if is_revealed(c): return "target_revealed" # 旗不拦挖掘(插旗不可逆批 2026-09-19):有旗格直接挖 = 对承诺的兑现,兑付改判归 战斗结算。 return "" # 连锁闭包:μX.[O∪{c} ⊆ X ∧ ∀x∈X:(x∈S ∧ num(x)=0 ⇒ N₈(x)∩S∖G ⊆ X)]——连锁不越旗 func _chain_closure(start: Vector2i, flag_set: Dictionary) -> Dictionary: var chain := {start: true} var queue: Array = [start] while not queue.is_empty(): var c: Vector2i = queue.pop_front() if _board.is_safe(c) and not flag_set.has(c) and _board.digit_of(c) == 0: for n in _board.neighbors8(c): if _board.is_safe(n) and not flag_set.has(n) and not chain.has(n): chain[n] = true queue.append(n) return chain # 揭示规范序:首格 + 触发格 BFS、8 邻行主序 func _chain_reveal_order(start: Vector2i, flag_set: Dictionary) -> Array: var order: Array = [start] var seen := {start: true} var queue: Array = [start] while not queue.is_empty(): var c: Vector2i = queue.pop_front() if _board.is_safe(c) and not flag_set.has(c) and _board.digit_of(c) == 0: for n in _board.neighbors8(c): if _board.is_safe(n) and not flag_set.has(n) and not seen.has(n): seen[n] = true order.append(n) queue.append(n) return order func _cleared_after(chain: Dictionary) -> bool: for c in _board.safe_cells(): if not _revealed.has(c) and not chain.has(c): return false return true # ---- 旗 ---- func _do_flag_place(c: Vector2i) -> Dictionary: var sub := "" if not _board.in_bounds(c): sub = "out_of_catalog" elif not _adjacent_to_hero(c): sub = "not_adjacent" elif is_revealed(c): sub = "target_revealed" elif has_flag(c): sub = "flag_present" if sub != "": return _outcome(false, "rejected", "board_face", sub) if not action_economy.try_spend("flag_place"): return _outcome(false, "rejected", "resource_face", "") _flags[c] = true # 旗动作零格子事件外发(发射面封闭;反馈经调用返回值命令式驱动) if action_economy.phase != action_economy.PHASE_ACTIVE: # 旗动作花完末点 AP 同样终结轮次(行动经济 三出口——AP 用完与动作种类无关;缺漏使 round_end # 永不发出、战斗结算 的 Waiting 结算点永不到达 = 整局软锁。playtest 缺陷②,行为探针复现) _emit_round_end_flags_and_payload() return _outcome(true, "executed", "", "") # ---- 移动(瞬移;免费不经账本;BFS 限已探明区含已引爆雷格)---- func _do_move(c: Vector2i) -> Dictionary: var sub := "" if not _board.in_bounds(c): sub = "out_of_catalog" elif not is_revealed(c): sub = "target_concealed" elif c != _hero and compute_move_path(c).is_empty(): sub = "no_path" if sub != "": return _outcome(false, "rejected", "board_face", sub) _hero = c # 目标 = 当前格定义为零移动(幂等受理) return _outcome(true, "executed", "", "") func compute_move_path(target: Vector2i) -> Array: if _board == null or not is_revealed(target): return [] if target == _hero: return [] # BFS(8 邻行主序)——路径 = 合法性载体 + HUD框架预览数据源 var prev := {_hero: _hero} var queue: Array = [_hero] while not queue.is_empty(): var c: Vector2i = queue.pop_front() if c == target: break for n in _board.neighbors8(c): if is_revealed(n) and not prev.has(n): prev[n] = c queue.append(n) if not prev.has(target): return [] var path: Array = [] var cur := target while cur != _hero: path.push_front(cur) cur = prev[cur] return path # ---- 停手(经批次入口委托 行动经济;先发旗判定批再委托——事件序契约 flag_judged < round_end 全路径统一)---- func _do_stop() -> Dictionary: _emit_flags_batch() # 基线 = 事务开始旗位(停手不改旗位) if not action_economy.request_stop(): return _outcome(false, "rejected", "phase", "") # 行动经济 直发 stopped 路径 round_end;排雷玩法 禁双发(双发射源纪律) return _outcome(true, "executed", "", "") # ---- 轮末旗判定 + 棋盘路径 round_end 外发 ---- func _emit_flags_batch() -> void: # 判定基线 = 轮次结束事务开始时旗位;行主序枚举(确定性);被拔的不判;诚实重复上报(去重归 战斗结算) var cells: Array = _flags.keys() cells.sort_custom(func(a, b): return a.y * 1000 + a.x < b.y * 1000 + b.x) for c in cells: flag_judged.emit({"cell": c, "hit": _board.is_mine(c)}) func _emit_round_end_flags_and_payload() -> void: _emit_flags_batch() var payload: Dictionary = action_economy.last_round_end # 逐字段取自 行动经济(读取权 = 接口契约) round_end_board.emit({"path_tag": payload.get("path_tag", ""), "continuation_hint": payload.get("continuation_hint", "")}) func _adjacent_to_hero(c: Vector2i) -> bool: var d := Vector2i(absi(c.x - _hero.x), absi(c.y - _hero.y)) return maxi(d.x, d.y) == 1 func _outcome(accepted: bool, outcome: String, reason: String, sub: String) -> Dictionary: return {"accepted": accepted, "outcome": outcome, "reject_reason": reason, "sub_reason": sub}