Files
gplan-2/game/core/minesweeper_rules.gd
T
DKChan a57489e970 feat(fx): 迭代20 T1 效果引擎词表扩签+发射点施加面(专长实装批 A段一)
词表:EVENTS 16→18(+dig_stop/battle_won)、FACTS 9→17(8 新事实含 hp_ratio 生命比例前置兑付)、
EFFECT_KINDS +7 新类型(heal/ac_bonus/damage_reduce/bonus_delta/blast_scaled/preview_area/
target_attack_bonus)、OPS +mod_eq;DO_KEYS +4 键(value_event/value_counter/domain/axis)。
rewrite_board 暂留至 T3 删行同批除名(purge_sapper 仍在表),消费分支已摘除。

施加面:battle_orchestrator(dig_reveal 三事实/mine_hit 五新施加/terrain 减伤前置/flag_hit 四新/
round_end GP+AP 队列/battle_won 发射/bonus_delta 战末汇入/ac_bonus 累积器/揭示队列_.flush_pending_reveals)
+damage_controller(attack value_counter rider/敌技 damage_reduce/hp_ratio fact/kill 揭示回调)
+action_economy(dig_paid 返还/dig_stop 发射/上轮耗尽旗标)+minesweeper fx_reveal 效果通道
(只揭真格信息红线)+rng fx_reveal 分签。

T6 承载:EffectEngine adventure 域计数器(domain 键/counters_export/counters_seed)+
AdventureRunner.adventure_counters 共享引用(attune_charges 先例)+断点随档。

测试:effect_words_t1_test 新套(V1:新词在册/出域四路被拒/seed-export 往返/mod_eq 预评估口径)
入 run_all 19 套全绿;冒烟 846/0 持平基线零破坏。
2026-10-11 22:42:51 +08:00

494 lines
19 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/gdd/systems/排雷循环/排雷玩法.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 由 行动经济 直发)
var db: ConfigDb
var action_economy: ActionEconomy
# 棋盘状态私有(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 _removed := {} # Vector2i → true(迭代 13 T8/D6——中立移除雷格集;随 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
# ---- 板态 ----
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
# ---- 中立移除(迭代 13 T8/D6——P1 甲:邻接数字=原始雷数冻结零重算;P2 甲:移除格呈现退色降档;
# 移除集=未揭示、无旗、是雷——旗判定基线零回溯;雷格不入清图分母/连锁/C_dig 模拟,判定逻辑零改动) ----
func remove_mines_in(cells: Array) -> Array:
var out: Array = []
for c in cells:
var cell: Vector2i = c
if not _board.in_bounds(cell) or is_revealed(cell) or has_flag(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 = 轮内刷新编排路径(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()
_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 flag_count() -> int:
return _flags.size()
# during_round = 轮内刷新编排路径(2026-09-12 方案 A):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()
_flags.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 = 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[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 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"
if _removed.has(c):
return "target_removed" # 迭代 13 T8/D6——中立移除格不可交互(拒绝先于消耗,pattern 1 board_face 腿)
# 旗不拦挖掘(插旗不可逆批 2026-09-19):有旗格直接挖 = 对承诺的兑现,兑付改判归 战斗结算。
return ""
# fx_reveal 效果通道揭示(迭代 20 专长实装批 T1——订阅类「揭示 1 格」效果的棋盘面执行口:
# 不经行动经济〔零 AP 消耗零相位要求〕、不要求邻接〔任意揭示〕、只受理安全格〔信息红线=只揭真格,
# 雷格/已开/出界/移除格=拒绝零副作用〕。连锁与揭示事件与 _do_dig 同构(chain 通道复用),资源兑付归
# 战斗结算 _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, _flags)
for rc in _chain_reveal_order(c, _flags):
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,
"dug_under_flag": false,
})
return true
# 连锁闭包:μ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"
elif _removed.has(c):
sub = "target_removed" # 迭代 13 T8/D6——中立移除格不可交互
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[Vector2i] = []
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:
# 判定基线 = 轮次结束事务开始时旗位;行主序枚举(确定性——排序键单点 BoardData.row_major_key,
# 2026-10-02 修复批);被拔的不判;诚实重复上报(去重归 战斗结算)
var cells: Array = _flags.keys()
cells.sort_custom(func(a, b): return BoardData.row_major_key(a) < BoardData.row_major_key(b))
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}