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

439 lines
16 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
# 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}