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

397 lines
15 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
# 确定性棋盘生成器:五阶段管线(参数→布雷→变异布置→生成-验证→产出)+ F1 可推理率度量
# (single_subset 求解器)+ F5 验收算子(达标即出/接受最优/接受最不坏)。
# 切片裁剪登记:黄金向量回归套件与 F4 批量工具不做(难度曲线与Boss 调参期工具域)。
# 实现 GDD:design/gdd/systems/排雷循环/棋盘生成.md(系统 ID A1·棋盘生成——registry 追溯锚,命名规范见 CLAUDE.md)
class_name BoardGenerator
extends RefCounted
const METRIC_VERSION := 1
# ==================== 生成入口 ====================
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。
# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表(2026-10-02 注)
func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData:
var row: Dictionary = db.resolve_board_gen(L, D)
if row.is_empty():
_fatal("BoardGen 行缺失 (%d,%d)" % [L, D])
return null
# 生成期表值单次读取快照(防热重载撕裂 attempt 语义)
var params := {
"L": L, "D": D,
"w": int(row.board_width), "h": int(row.board_height),
"density": float(row.mine_density),
"target": float(row.inferability_target),
"solver_rules": String(row.solver_rules),
"min_safe": int(row.min_safe_count),
"max_attempts": int(row.max_gen_attempts),
"board_type": String(row.board_type),
# 初始展开收窄批(迭代14① T1——两新参数;.get 缺省=legacy 调用方防御口径:shave_r=1 现行投值、
# r0_cap=0 闸关。正式表行全行投值,加载期校验在 BoardGenTable.validate)
"shave_r": int(row.get("shave_ring_radius", 1)),
"r0_cap": int(row.get("r0_max_cap", 0)),
}
var best_legal: BoardData = null
var best_legal_score := -1.0
var best_bad: BoardData = null
var best_badness := [999999, 999999, 999999]
var attempts_used := 0
var accepted: BoardData = null
var accept_mode := ""
var r0_cap_rejected := 0
for k in range(1, params.max_attempts + 1):
attempts_used = k
var b := _attempt(params, rng_stream, db)
var badness := _badness(b, params)
if best_bad == null or _lex_less(badness, best_badness):
best_bad = b
best_badness = badness
# R₀≤cap 重掷闸触发计数(迭代14① T3——尝试事实,与合法性判定独立记录;占比=T5 闸触发率读数)
if int(params.r0_cap) > 0 and b.compute_r0().size() > int(params.r0_cap):
r0_cap_rejected += 1
if legality(b, params, db):
var m: Dictionary = measure_inferability(b) # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
var inf: float = m["inferability"]
if best_legal == null or inf > best_legal_score:
best_legal = b
best_legal_score = inf
if inf >= params.target: # 单一判据(棋盘生成:现行规则已回归"可推理率 ≥ 及格线")
accepted = b
accept_mode = "first_pass"
break
if accepted == null:
if best_legal != null:
accepted = best_legal
accept_mode = "accept_best"
accepted.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛)
push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
else:
accepted = best_bad
accept_mode = "accept_worst"
accepted.illegal_product = true
push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
_finalize(accepted, params, attempts_used, accept_mode, db, r0_cap_rejected)
return accepted
# ==================== 五阶段:单次尝试 ====================
func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> BoardData:
var b := BoardData.new()
b.width = params.w
b.height = params.h
b.spawn_center = Vector2i(int((params.w - 1) / 2.0), int((params.h - 1) / 2.0))
b.board_type = str(params.board_type)
b.level_index = params.L
b.node_depth = params.D
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"
# (−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS——2026-10-02 修复批)
var spawn := {}
for c in b.spawn_cells():
spawn[c] = true
var domain: Array[Vector2i] = []
for c in b.all_cells():
if not spawn.has(c):
domain.append(c)
var n_mines: int = int(round(params.density * float(params.w * params.h - BoardData.SPAWN_AREA_CELLS)))
# 部分 Fisher-Yates(确定性,消费 board_gen 流)
for i in range(0, mini(n_mines, domain.size())):
var j := rng_stream.randi_range(i, domain.size() - 1)
var tmp = domain[i]
domain[i] = domain[j]
domain[j] = tmp
for i in range(0, mini(n_mines, domain.size())):
b.mines[domain[i]] = true
_compute_digits(b)
# 先布后删:剃雷域 = 距 spawn_center 切比雪夫距离 ≤1+r 的格(迭代14①收窄批 T2——原「R₀ flood 域∪
# 外扩 1 环」全域剃雷=开局开阔地巨大根因〔10×10 板 R₀ 均值 68.6=板面 68.6%〕;收窄后开阔地=
# 出生邻域外的自然 flood。r 读表 shave_ring_radius(r=1 → 5×5=25 格;r=0 即只剃出生 3×3)。
# 只减不增、无迭代语义不变;ring_removed 指标语义不变(现值变小=预期);零 RNG 消费变化——
# 同种子布雷序列逐位一致,仅移除域收窄。
var shave_r: int = int(params.get("shave_r", 1))
var ring := {}
for c in b.all_cells():
if maxi(absi(c.x - b.spawn_center.x), absi(c.y - b.spawn_center.y)) <= 1 + shave_r:
ring[c] = true
var removed := 0
for c in ring.keys():
if b.mines.has(c):
b.mines.erase(c)
removed += 1
_compute_digits(b)
# ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位),
# 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
}
return b
func _compute_digits(b: BoardData) -> void:
b.digits.clear()
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
b.digits[c] = d
# ==================== F1 可推理率度量(独立入口——与内嵌调用同一实现) ====================
# 返回 {r0, r_inf_count, inferability}
func measure_inferability(b: BoardData) -> Dictionary:
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var r := {}
for c in b.compute_r0():
r[c] = true
var known_mines := {}
var changed := true
while changed:
changed = false
# 算子① 连锁:0 格的 8 邻安全格全入 R
var frontier: Array = r.keys()
for x in frontier:
if safe_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
# 信息源:数字约束 single + subset(solver_rules=single_subset 冻结域)
var constraints := _build_constraints(b, r, known_mines, safe_set)
# single 规则
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
# subset 规则:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary = constraints[i]
var bb: Dictionary = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
for con in derived:
if con.mines == 0:
for c in con.unknown:
if not r.has(c): # 同 no-peek 口径:推导结论不再用 safe_set 复核(约束本身可靠)
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
var r_inf := r.keys()
var s_count := safe_set.size()
var inferability: float = float(r_inf.size()) / float(maxi(1, s_count))
return {
"r0": b.compute_r0().size(),
"r_inf_count": r_inf.size(),
"inferability": inferability,
}
func _build_constraints(b: BoardData, r: Dictionary, known_mines: Dictionary, safe_set: Dictionary) -> Array:
var out: Array[Dictionary] = []
for c in r.keys():
if not safe_set.has(c):
continue # 只对已开安全格的数字建约束
var d := b.digit_of(c)
var unknown := {}
var known_here := 0
for n in b.neighbors8(c):
if known_mines.has(n):
known_here += 1
elif not r.has(n):
unknown[n] = true # 未揭示格一律计入未知(含雷格)——求解器禁读 safe_set 真值(棋盘生成:雷位不许偷看)
var d_eff := d - known_here
if d_eff < 0 or (d_eff > 0 and unknown.is_empty()):
continue # 矛盾态(非法板)——不产约束,验收由 legality 闸承担
if not unknown.is_empty():
out.append({"mines": d_eff, "unknown": unknown})
return out
func _subset_of(a: Dictionary, b: Dictionary) -> int:
# 1: a ⊂ b;2: b ⊂ a;0: 互不包含
var a_in_b := true
var b_in_a := true
for k in a.keys():
if not b.has(k):
a_in_b = false
for k in b.keys():
if not a.has(k):
b_in_a = false
if a.size() < b.size() and a_in_b:
return 1
if b.size() < a.size() and b_in_a:
return 2
return 0
func _diff_constraint(big: Dictionary, small: Dictionary) -> Dictionary:
var diff := {}
for k in big.unknown.keys():
if not small.unknown.has(k):
diff[k] = true
return {"mines": big.mines - small.mines, "unknown": diff}
# ==================== 合法性闸(独立入口;F5 legality + R₀≠S 修订义务) ====================
func is_clearable(b: BoardData) -> bool:
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var seen := {}
var queue: Array = b.spawn_cells().duplicate()
for c in queue:
seen[c] = true
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if not safe_set.has(c):
continue
for n in b.neighbors8(c):
if safe_set.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
for c in safe_set.keys():
if not seen.has(c):
return false
return true
func legality(b: BoardData, params: Dictionary, db) -> bool:
var reasons := _legality_faults(b, params)
return reasons.is_empty()
func _legality_faults(b: BoardData, params: Dictionary) -> Array:
var faults: Array[String] = []
# ① 出生区无雷 ② 出生区∪R₀ 无变异 ③ 数字恒真 ④ R₀ ≠ S(排雷玩法 修订义务)
for c in b.spawn_cells():
if b.is_mine(c):
faults.append("inv1_mine_in_spawn")
var r0 := {}
for c in b.compute_r0():
r0[c] = true
if b.variant_ids(c).size() > 0:
faults.append("inv2b_variant_in_r0")
for c in b.spawn_cells():
if b.variant_ids(c).size() > 0:
faults.append("inv2a_variant_in_spawn")
for c in b.all_cells():
if b.is_safe(c) and b.digit_of(c) != _neighbor_mine_count(b, c):
faults.append("inv3_digit_invariant")
if r0.size() == b.safe_cells().size():
faults.append("inv4_r0_equals_s")
# clearable(合法性闸——与质量度量两闸分离)
if not is_clearable(b):
faults.append("not_clearable")
# min_safe 产物侧(容错两种入参形态:generate 的 params 快照 / 原始表行)
var min_safe: int = int(params.get("min_safe", params.get("min_safe_count", 0)))
if b.safe_cells().size() < min_safe:
faults.append("min_safe_short")
# ⑤ R₀≤cap 重掷闸(迭代14①收窄批 T3——合法性先于质量评分,同 inv4 白给板先例;超阈整板作废
# 同流重试〔重试共用 board_gen 流,attempt 边界非 scope 事件〕;全部尝试超阈 → 既有三分出口
# accept_worst 诚实降级+非法产物标记(玩家永不卡死)。cap=0 = 闸关(legacy 调用方防御口径)
var r0_cap: int = int(params.get("r0_cap", 0))
if r0_cap > 0 and r0.size() > r0_cap:
faults.append("r0_cap_exceeded")
return faults
func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int:
var d := 0
for n in b.neighbors8(c):
if b.is_mine(n):
d += 1
return d
# ==================== 产出上报(生成期指标集) ====================
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, _db, r0_cap_rejected: int = 0) -> void:
var m := measure_inferability(b)
# 产物断言(棋盘生成 出生区安全与反退化布雷):实际雷数 ≤ 目标雷数 N——先布后删只减不增,失败即致命
var n_target: int = int(round(float(params.density) * float(params.w * params.h - BoardData.SPAWN_AREA_CELLS)))
if b.mines.size() > n_target:
_fatal("产物断言违例:实际雷数 %d > 目标雷数 N=%d(先布后删只减不增)" % [b.mines.size(), n_target])
var attempt_facts := b.metrics # 保留尝试期事实(ring_removed)
b.metrics = {
"metric_version": METRIC_VERSION,
"inferability": m.inferability,
"actual_mines": b.mines.size(),
"target_mines": n_target,
"actual_safe_count": b.safe_cells().size(),
"attempts_used": attempts_used,
"accept_mode": accept_mode,
"degraded_product": accept_mode == "accept_best",
"illegal_product": accept_mode == "accept_worst",
"ring_removed": attempt_facts.get("ring_removed", 0),
"r0_count": m["r0"],
# R₀≤cap 重掷闸尝试事实(迭代14① T3——占比=T5 闸触发率读数,过高=甲乙配比失衡信号)
"r0_cap_rejected": r0_cap_rejected,
}
b.illegal_product = accept_mode == "accept_worst"
b.degraded_product = accept_mode == "accept_best"
func _badness(b: BoardData, params: Dictionary) -> Array:
# F5 k*** 确定性劣度字典序:(不可达安全格数, min_safe 缺口, 序号)
var unreachable := 0
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var seen := {}
var queue: Array = b.spawn_cells().duplicate()
for c in queue:
seen[c] = true
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if not safe_set.has(c):
continue
for n in b.neighbors8(c):
if safe_set.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
for c in safe_set.keys():
if not seen.has(c):
unreachable += 1
var gap: int = maxi(0, int(params.min_safe) - b.safe_cells().size())
return [unreachable, gap, 0]
func _lex_less(a: Array, b: Array) -> bool:
for i in a.size():
if a[i] < b[i]:
return true
if a[i] > b[i]:
return false
return false
func _fatal(msg: String) -> void:
push_error("[棋盘生成/fatal] " + msg)
assert(false, msg)