Files
gplan-2/game/core/minesweeper_rules.gd
T

572 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 排雷规则引擎:棋盘产物+玩家意图批次 → 揭示/封印判定/轮末事件流,install 四不变式守门(垂直切片验证:挖掘逐行归因)。
## 零随机、零表读取、零 UI——判定只依赖棋盘事实与动作序列。
## 板态对外只经 current_board()/reveal_record() 等查询面,禁跨模块直读写内部字典;轮末双发射源之棋盘路径由 round_end_board 外发。
class_name MinesweeperRules
extends RefCounted
## 揭示事实流:每格首次进入已开集恰发一次(已开格零事件)。
## 载荷键集(四处发射位并集):cell: Vector2i/kind: "safe"|"mine"/digit: int(安全格邻雷数;危险格 -1)/
## variant_ids: Array(地形变异身份,危险格空集)/channel: "initial"|"dig"|"chain"|"fx_reveal"/
## resource: bool(格面资源兑付资格:initial=false,其余 true)。
## 发送时点:initial=install_board 初始展开逐格;dig/chain=_do_dig 揭示闭包内(本格 AP 扣费在其后);
## fx_reveal=fx_reveal 闭包内(零 AP 消耗零相位要求)。
## 消费方依赖:战斗结算 _on_cell_revealed 以 resource 分流缓冲(fx_reveal 由兑付点直入账,禁二次缓冲)、
## 以 kind 驱动积点计数、以 cell 取奖励快照——禁改键名与语义;事件序 cell_revealed 批先于 seal_judged 批。
signal cell_revealed(evt: Dictionary)
## 封印判定事实流:轮末事务开始时的封印位逐格判定(行主序确定性枚举,被拔的不判)。
## 载荷键集:cell: Vector2i(封印格)/hit: bool(该格是否为雷)。
## 发送时点:停手路径 _do_stop 委托行动经济之前;棋盘路径揭示闭包收尾(phase 非 Active 时)——
## 全路径统一先于 round_end 发射(事件序契约 seal_judged < round_end)。
## 消费方依赖:战斗结算 _on_seal_judged 收批后统一开封印命中单——载荷缺键即错判,禁改键名与语义。
signal seal_judged(evt: Dictionary)
signal round_end_board(payload: Dictionary) ## 双发射源之棋盘路径(排雷玩法 外发;stopped 由 行动经济 直发)
var db: ConfigDb
var action_economy: ActionEconomy
# 秘宝常时参数注入面(编排器 begin_battle 按装配集 kind 分发注入:
# dig_range_bonus=挖掘切比雪夫距离放宽〔远程镐 dig_range_bonus magnitude〕/seal_range_free=
# 封印不限相邻〔信标网络〕;缺省 0/false=零装配零扰动,棋盘事实不可变契约不受影响——
# 两参数只放宽「动作可达性」判据,禁碰危险格/安全格分布)
var dig_range_bonus := 0
var seal_range_free := false
# 棋盘状态私有(E2 封装收口):对外只暴露 current_board()/reveal_record() 等查询面,
# 禁止跨模块直读/直写原始字典。
var _board: BoardData = null
var _revealed := {} # Vector2i → {"kind": "safe"/"mine", "digit": int}
var _seals := {} # Vector2i → true(棋盘单态;跨轮保留随图存续)
var _removed := {} # Vector2i → true(中立移除危险格集;随 install/retire 清空)
var _hero := Vector2i.ZERO
var _spawn: Dictionary[Vector2i, bool] = {}
func _init(db_ref: ConfigDb, action_economy_ref: ActionEconomy) -> void:
db = db_ref
action_economy = action_economy_ref
# ---- 板态 ----
# 板产物只读出口(E2 收口——战斗结算 快照 / 组合根 rebuild / 探针经此取板,禁再直读字段)
func current_board() -> BoardData:
return _board
# 伤害型变异过滤(terrain_kind fact 取数面——variation 表 effect=flat_damage 判据;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型(hide_number 族)永不出现在本集。自编排器归位)
func harmful_variants(vids: Array) -> Array:
var out: Array[Variant] = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(vid)
if String(vrow.get("effect", "")) == "flat_damage":
out.append(vid)
return out
# 邻接伤害型变异收集(terrain_kind fact 取数面——seal_hit 发射点用:封印命中相邻八格的伤害型变异集;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型〔hide_number 族〕不入集。自编排器归位)
func harmful_adjacent(cell: Vector2i) -> Array:
var out: Array[Variant] = []
var board: BoardData = current_board()
if board == null:
return out
for n in board.neighbors8(cell):
out.append_array(harmful_variants(board.variant_ids(n)))
return out
# 已开格记录查询面(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_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) and not _seals.has(c):
n += 1
return n
func clear_map_cleared() -> bool:
return _safe_remaining() == 0
# ---- 中立移除(P1 甲:邻接数字=原始雷数冻结零重算;P2 甲:移除格呈现退色降档;
# 移除集=未揭示、无封印、是雷——封印判定基线零回溯;危险格不入清图分母/连锁/C_dig 模拟,判定逻辑零改动) ----
func remove_mines_in(cells: Array) -> Array:
var out: Array[Variant] = []
for c in cells:
var cell: Vector2i = c
if not _board.in_bounds(cell) or is_revealed(cell) or has_seal(cell) or not _board.is_mine(cell):
continue
if _removed.has(cell):
continue
_removed[cell] = true
out.append(cell)
return out
func is_removed(c: Vector2i) -> bool:
return _removed.has(c)
# 未开格存在查询(aux 道具 usable 预判消费——全板扫描只读面)
func has_unrevealed() -> bool:
for y in _board.height:
for x in _board.width:
if not _revealed.has(Vector2i(x, y)):
return true
return false
# ---- install(编排边界相位 = 行动经济 Idle/Waiting;Active 拒绝——防轮内换板)----
# during_round = 轮内刷新编排路径(战斗结算 唯一调用方):Active 相位合法——
# 玩家挖掘中立即换图,棋盘状态(revealed/seals)随 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()
_seals.clear()
_removed.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", "", "")
func seal_count() -> int:
return _seals.size()
# during_round = 轮内刷新编排路径:Active 相位合法——换图前退役旧板
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()
_seals.clear()
_removed.clear()
return _outcome(true, "executed", "", "")
# 四不变式(诊断类别封闭枚举):inv1 出生区雷 / inv2a 出生区变异 / inv2b R₀ 变异 / inv3 数字恒真 / inv4 R₀=S
func _install_invariant_faults(b: BoardData) -> Array:
var faults: Array[String] = []
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[Variant] = b.spawn_cells().duplicate()
var seen := {}
for c in order:
seen[c] = true
var queue: Array[Variant] = 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[Dictionary] = []
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[Dictionary] = []
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 chain := _chain_closure(c, _seals)
var remaining := 0
for sc in _board.safe_cells():
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":
return _do_dig(intent.get("cell", Vector2i(-9, -9)))
"seal_place":
return _do_seal_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)
# 真模拟连锁与清图(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
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
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", "", "")
## 连锁揭示域:首格+连锁新格揭示(BFS 规范序——cell_revealed 发射序即链序,整体随段搬迁)。
## 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带,
## 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
func _reveal_chain(c: Vector2i, reveal_order: Array) -> void:
for rc in reveal_order:
if _revealed.has(rc):
continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|)
var channel := "dig" if rc == c else "chain"
if _board.is_mine(rc):
_revealed[rc] = {"kind": "mine", "digit": -1}
cell_revealed.emit({
"cell": rc, "kind": "mine", "digit": -1,
"variant_ids": [], "channel": channel, "resource": true,
})
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,
})
func _board_face_dig(c: Vector2i) -> String:
if not _board.in_bounds(c):
return "out_of_catalog"
if not _within_dig_range(c):
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 ""
# fx_reveal 效果通道揭示(订阅类「揭示 1 格」效果的棋盘面执行口:
# 不经行动经济〔零 AP 消耗零相位要求〕、不要求邻接〔任意揭示〕、只受理安全格〔信息红线=只揭真格,
# 危险格/已开/出界/移除格=拒绝零副作用〕。连锁与揭示事件与 _do_dig 同构(chain 通道复用),资源兑付归
# battle_fx_host flush_pending_reveals 直接入账——channel=fx_reveal 供归因)
func fx_reveal(c: Vector2i) -> bool:
if _board == null or not _board.in_bounds(c) or is_revealed(c) or _board.is_mine(c) or _removed.has(c):
return false
var chain := _chain_closure(c, _seals)
for rc in _chain_reveal_order(c, _seals):
if _revealed.has(rc):
continue
_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": "fx_reveal", "resource": true,
})
return true
## 挖一格→真实翻开集只读预览(sim 信息面出口——与 _classify 同口径:连锁不越封印、
## 真封印集口径;复用 _chain_closure 闭包;零状态写入、零事件发射)
func preview_reveal(c: Vector2i) -> Array[Vector2i]:
var closure := _chain_closure(c, _seals)
var out: Array[Vector2i] = []
for cc in closure.keys():
if cc != c and not _revealed.has(cc):
out.append(cc) # F2 口径:新开格集——不含首格与已开格
return out
# 连锁闭包:μX.[O∪{c} ⊆ X ∧ ∀x∈X:(x∈S ∧ num(x)=0 ⇒ N₈(x)∩S∖G ⊆ X)]——连锁不越封印
func _chain_closure(start: Vector2i, seal_set: Dictionary) -> Dictionary:
var chain := {start: true}
var queue: Array[Variant] = [start]
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if _board.is_safe(c) and not seal_set.has(c) and _board.digit_of(c) == 0:
for n in _board.neighbors8(c):
if _board.is_safe(n) and not seal_set.has(n) and not chain.has(n):
chain[n] = true
queue.append(n)
return chain
# 揭示规范序:首格 + 触发格 BFS、8 邻行主序
func _chain_reveal_order(start: Vector2i, seal_set: Dictionary) -> Array:
var order: Array[Variant] = [start]
var seen := {start: true}
var queue: Array[Variant] = [start]
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if _board.is_safe(c) and not seal_set.has(c) and _board.digit_of(c) == 0:
for n in _board.neighbors8(c):
if _board.is_safe(n) and not seal_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:
# 清图口径 = 已挖 ∪ 已封印(与 _safe_remaining 同源——私有共享口径单点,禁第三处重复实现)
for c in _board.safe_cells():
if not _revealed.has(c) and not chain.has(c) and not _seals.has(c):
return false
return true
# ---- 封印 ----
# 封印棋盘面合法性(board_face 拒绝情形序:目录外/不相邻/已开/已有封印/已移除——与挖掘合法性同构分域)
func _seal_face_reject(c: Vector2i) -> String:
if not _board.in_bounds(c):
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 结算点永不到达 = 整局软锁);清图收轮同此出口发判定批
_emit_round_end_seals_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[Variant] = [_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[Vector2i] = []
var cur := target
while cur != _hero:
path.push_front(cur)
cur = prev[cur]
return path
# ---- 停手(经批次入口委托 行动经济;先发封印判定批再委托——事件序契约 seal_judged < round_end 全路径统一)----
func _do_stop() -> Dictionary:
_emit_seals_batch() # 基线 = 事务开始封印位(停手不改封印位)
if not action_economy.request_stop():
return _outcome(false, "rejected", "phase", "")
# 行动经济 直发 stopped 路径 round_end;排雷玩法 禁双发(双发射源纪律)
return _outcome(true, "executed", "", "")
# ---- 轮末封印判定 + 棋盘路径 round_end 外发 ----
func _emit_seals_batch() -> void:
# 判定基线 = 轮次结束事务开始时封印位;行主序枚举(确定性——排序键单点 BoardData.row_major_key);
# 被拔的不判;诚实重复上报(去重归 战斗结算)
var cells: Array[Variant] = _seals.keys()
cells.sort_custom(func(a, b): return BoardData.row_major_key(a) < BoardData.row_major_key(b))
for c in cells:
seal_judged.emit({"cell": c, "hit": _board.is_mine(c)})
func _emit_round_end_seals_and_payload() -> void:
_emit_seals_batch()
var payload: Dictionary[Variant, Variant] = action_economy.last_round_end() # 逐字段取自 行动经济(读取权 = 接口契约;属性→方法 K5)
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
# 挖掘可达判据(远程镐 dig_range_bonus 放宽:切比雪夫距离 ≤ 1+加值,
# 加值 0=恰相邻;开格仍走既有格规则=不挖开不相邻事实之外 anything)
func _within_dig_range(c: Vector2i) -> bool:
var d := Vector2i(absi(c.x - _hero.x), absi(c.y - _hero.y))
return maxi(d.x, d.y) <= 1 + dig_range_bonus
# 本板危险格总数(巢穴预告「装板后揭示本板危险格数量」取数口;
# 只读投影 BoardData.mines 规模,零分布泄露——数量非位置)
func mine_total() -> int:
return _board.mines.size() if _board != null else 0
func _outcome(accepted: bool, outcome: String, reason: String, sub: String) -> Dictionary:
return {"accepted": accepted, "outcome": outcome, "reject_reason": reason, "sub_reason": sub}