feat(terrain-variation): 地形变异首批类型定稿批——四类型两轴×两关落地(台账 92)

- 首批四类型(2026-09-19/20 用户拍板):荆棘丛/藤蔓缠绕(L1 dungeon 平原密林)+ 岩浆/烬覆(L2 underdark 幽暗地域);伤害型=挖开轮末结算扣血(1/2 点),信息型=呈现层数字遮蔽
- 机制枚举化:effect/trigger/placement 三枚举 + 跨字段互锁(schema 重写),新增类型=加行不改代码
- 布置段移排战斗初始化第 4 步(生成棋盘→打开状态初始化→资源格→变异格,用户拍板顺序):_place_variants 数量=5%+每关+2、连通片≤4、安全格减展开区减雷格、每图单类型、variant_place 新封闭流
- biome_tags→board_types 更名(用户裁定对齐 board_type 词表);counter_items 字段位预留归 Alpha 反制批
- 冒烟 353 项全绿(+22 项变异断言);四场景探针全过;600 局×2 关模拟读数 L1 91.3/93.3、L2 78.3/81.7,承伤带全过
- 文档收口:地形变异 v0.3、棋盘生成四阶段化、战斗结算随机消费六处、registry variation_table/variant_pool_gate/rng_service、06 清单四条目收口+两条目改排 Alpha、00/02/03/05 过期提法清理
This commit is contained in:
DKChan committed 2026-09-20 01:08:14 +08:00
1 parent 87fec0278a
commit ce47064627
20 files changed
+448 -160

No files matched your search

@@ -150,6 +150,9 @@ func _install_new_board(during_round := false) -> bool:
if not _reward_economy.roll_board(eligible):
_fatal("地图战斗资源 roll_board 被拒")
return false
# 地形变异 布置段(2026-09-19 用户拍板棋盘设置顺序第 4 步:生成棋盘 → 打开状态初始化 →
# 战斗资源格设置 → 变异格设置)——布置在初始展开与资源格之后,变异天然不落已开区
_place_variants(board)
_flag_consumed.clear() # 换图新雷 = 新实例,资格自然重置(行动经济 Rule 8)
_flag_miss_seen.clear()
_perfect_hit_true = 0 # 板级完美计数随 install 重置
@@ -160,6 +163,58 @@ func _install_new_board(during_round := false) -> bool:
return true
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步)----
# 候选池门控(unlock_level ≤ 关卡序号 + board_types 与本地形交集,∅ 通配)经 config_db.variant_pool;
# 池空 = 合法态,静默零布置(框架 P0 口径不变)。零特判:本段只写格子变异数据快照,不改扫雷规则本体。
# 候选格域 = 安全格 − 初始展开区(伤害型 placement=closed_only 的结构保障——布置发生在初始展开之后,
# 已开格天然排除);雷格不吃变异(挖雷一层惩罚 + 变异伤害双层叠罚污染逐行归因,首版排除——provisional)。
# 每图至多一种类型(候选池内均匀选一,混型布置归因复杂留待后续批);数量 = round(总格数×percent) +
# (关卡序号−1)×per_level_bonus;连通片生长(8 邻),单片 ≤ pack_max(2×2 / 1×4 / 1×3+1 等形)。
func _place_variants(board: BoardData) -> void:
var pool: Array = db.variant_pool(int(encounter.level_index), String(board.board_type))
if pool.is_empty():
return
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var candidates: Array = []
for c in board.safe_cells():
if not r0.has(c):
candidates.append(c)
if candidates.is_empty():
return
var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次)
var vrow: Dictionary = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(board.width * board.height) * float(db.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus)
total = mini(total, candidates.size())
var placed := {}
while placed.size() < total:
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.variation.pack_max)))
var free: Array = []
for c in candidates:
if not placed.has(c):
free.append(c)
if free.is_empty():
break
var seed_c: Vector2i = free[stream.randi_range(0, free.size() - 1)]
placed[seed_c] = true
var pack: Array = [seed_c]
while pack.size() < pack_size:
var fringe: Array = []
for c in pack:
for n in board.neighbors8(c):
if candidates.has(n) and not placed.has(n) and not fringe.has(n):
fringe.append(n)
if fringe.is_empty():
break # 生长域耗尽——该片就地收束(数量不足总目标为合法态,候选域碎片化时触发)
var nxt: Vector2i = fringe[stream.randi_range(0, fringe.size() - 1)]
placed[nxt] = true
pack.append(nxt)
for c in placed.keys():
board.variants[c] = [String(vrow.variant_id)]
# ---- 事件缓冲(轮 facts 收集;结算触发 = Waiting 相位编排点,不依赖到达序)----
func _on_cell_revealed(evt: Dictionary) -> void:
@@ -219,6 +274,18 @@ func _settle() -> void:
# 插旗不可逆四情况②:插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常兑付——惊动巢穴),
# 标中不发(旗已随挖掘擦除、不入轮末判定基线)。
_grant_routed(snap, "negative", "mine_hit", evt.cell, ri, "")
# 地形变异 兑付段(授予段尾——触发枚举 on_open:挖开即伤、chain 通道同口径,轮末结算逐行兑现;
# 信息型 hide_number 不进结算——呈现层读取,见 board_view 数字遮蔽;雷格恒无变异——布置段排除,
# 双层叠罚不存在;插旗格直挖照吃变异伤害——地形后果同 mine_hit 口径,非奖励不作废)
for evt in _facts_reveals:
var vids: Array = evt.get("variant_ids", [])
for vid in vids:
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) != "flat_damage":
continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面)
var dmg: int = int(vrow.get("effect_value", 0))
_enemy_system.apply_damage(_enemy_system.hero_unit(), dmg)
_emit_row("damage", "地形变异", evt.cell, ri, "%s → 主角" % String(vrow.get("display_name", vid)), dmg, {"target": "hero", "variant_id": String(vid)}, "")
for evt in _facts_flags:
_judge_flag(evt, ri)
if _facts_path == "cleared":
@@ -301,14 +301,15 @@ func _ceil_key(sorted_keys: Array, request: int) -> int:
return -1
# ---- 地形变异 variant_pool_gate:候选池门控(∅ biome_tag = 通配)----
# ---- 地形变异 variant_pool_gate:候选池门控(∅ board_types = 通配;2026-09-19 用户裁定字段名对齐
# board_type 词表——biome_tags 系旧观念残留退役)----
func variant_pool(level_index: int, board_type: String) -> Array:
var pool: Array = []
for row in variation.rows:
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array = row.get("biome_tags", [])
var tags: Array = row.get("board_types", [])
if tags.is_empty():
pool.append(row)
continue
@@ -7,7 +7,7 @@
# 取号语义:全新实例首个 open_stream = scope 0(地图战斗资源 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批)+ behavior_roll(敌人技能池批——开轮摇号,每敌派生独立子流,registry rng_service 条目同步)
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll", "variant_place"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批)+ behavior_roll(敌人技能池批——开轮摇号,每敌派生独立子流,registry rng_service 条目同步);2026-09-19 增 variant_place(地形变异首批类型定稿批——变异布置流,一板恰一 scope)
# 分段表达式书写(数据配置 R2 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
@@ -16,6 +16,7 @@ const SALT_REWARD_ROLL := (0xE7037ED1 << 32) | 0xA0B428DB # 0xE7037ED1A0B428DB
const SALT_ENCOUNTER := (0x8EBC6AF0 << 32) | 0x9C88C6E3 # 0x8EBC6AF09C88C6E3(遭遇骰——registry stream_seed_derivation 权威值;旧值 0x5DEECE66D<<32 超 int64 被截断,黄金向量漂移)
const SALT_ARMOR_ROLL := (0xC2B2AE3D << 32) | 0x27D4EB2F # 0xC2B2AE3D27D4EB2F(护甲掷点流——互异奇常数 ≠ 乘数;黄金向量见冒烟/registry 同批登记)
const SALT_BEHAVIOR_ROLL := (0x85EBCA77 << 32) | 0x58C24E15 # 0x85EBCA7758C24E15(技能摇号流——互异奇常数 ≠ 乘数;黄金向量见冒烟/registry 同批登记)
const SALT_VARIANT_PLACE := (0x9AE16A3F << 32) | 0x2FAD6C39 # 0x9AE16A3F2FAD6C39(变异布置流——互异奇常数 ≠ 乘数;地形变异首批类型定稿批 2026-09-19)
# behavior_roll 派生向量(registry 同批登记):子流 scope = spawn_index × STRIDE + 单敌摇号序(0 起)。
# 每敌消费自己的 scope 段 → 单敌出招轨迹可独立复放、敌间互不穿插(proposal 约束二)。
const BEHAVIOR_SUBSTREAM_STRIDE := 1000000
@@ -27,7 +28,7 @@ const INT64_MIN := -9223372036854775808
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0, "behavior_roll": 0}
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0, "behavior_roll": 0, "variant_place": 0}
func _salt_for(stream: String) -> int:
@@ -42,6 +43,8 @@ func _salt_for(stream: String) -> int:
return SALT_ARMOR_ROLL
"behavior_roll":
return SALT_BEHAVIOR_ROLL
"variant_place":
return SALT_VARIANT_PLACE
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(流集合封闭,EC12)")
return 0
@@ -63,10 +66,11 @@ func set_master_seed(seed_val: int) -> void:
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0 and _counters["behavior_roll"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0 and _counters["behavior_roll"] == 0 and _counters["variant_place"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
_master_seed = seed_val
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
_counters["variant_place"] = 0
# 切片登记:冒险开始重播种入口(正式侧调用权归 冒险结构;切片主菜单 = 冒险起点)
@@ -76,6 +80,7 @@ func new_adventure(seed_val: int) -> void:
_counters["encounter"] = 0
_counters["armor_roll"] = 0
_counters["behavior_roll"] = 0
_counters["variant_place"] = 0
set_master_seed(seed_val)
@@ -1,16 +1,27 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 数据配置 VariationTable schema(地形变异 回填值;行 = Dictionary,键 variant_id)。
# 依据 design/archive/gdd-2026-09-14/a4-terrain-variation.md "VariationTable Schema 定稿"。
# 数据配置 VariationTable schema(地形变异 首批类型定稿批 2026-09-19;行 = Dictionary,键 variant_id)。
# 字段集(2026-09-19 用户裁定:枚举抽象,不写死类型):effect/effect_value/trigger/placement 四字段
# 把"变异是什么、在哪出现、何时起作用"全部数据化——新增类型 = 清单加行 + 必要时扩充枚举词表,不改读取方。
# biome_tags 更名 board_types(用户裁定:门控匹配对象即 board_type 词表,biome 系旧观念残留不沿用);
# counter_items 字段位保留(反制道具绑定,归 Alpha 反制批回填,切片期恒空)。
# info_mask/cell_constraint 两旧字段随枚举化退役(hide_number 并入 effect 词表,safe_nonzero 由
# 布置域结构保障——初始展开区天然排除,见 battle_orchestrator._place_variants)。
class_name VariationTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
# 布置参数(表头字段;数量公式 = round(总格数 × percent_of_cells / 100) + (关卡序号 − 1) × per_level_bonus,
# 连通片生长、单片 ≤ pack_max——2026-09-19 用户拍板:图规模百分比 × 关卡递增,全连通、单片不超 4)
@export var percent_of_cells: int = 5
@export var per_level_bonus: int = 2
@export var pack_max: int = 4
const SCOPES := ["region", "global"]
const INFO_MASKS := ["none", "hide_numbers"]
const CELL_CONSTRAINTS := ["any", "safe_nonzero"]
const EFFECTS := ["none", "flat_damage", "hide_number"] # 效果词表:直伤 / 数字遮蔽(可扩充)
const TRIGGERS := ["on_open", "on_number_shown"] # 触发词表:被打开即触发 / 打开且显示数字时触发(可扩充)
const PLACEMENTS := ["closed_only", "any"] # 布置约束词表:只落布置时未开格 / 任意格(可扩充)
func validate() -> Array:
@@ -32,13 +43,27 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not INFO_MASKS.has(row.get("info_mask", "")):
errs.append({"category": "enum", "message": "%s info_mask 出域: %s" % [vid, row.get("info_mask")]})
if not CELL_CONSTRAINTS.has(row.get("cell_constraint", "")):
errs.append({"category": "enum", "message": "%s cell_constraint 出域: %s" % [vid, row.get("cell_constraint")]})
# 跨字段(地形变异 re-review 增补,加载期致命):hide_numbers ⇒ safe_nonzero
if row.get("info_mask", "") == "hide_numbers" and row.get("cell_constraint", "") != "safe_nonzero":
errs.append({"category": "cross_field", "message": "%s hide_numbers ⇒ cell_constraint 必为 safe_nonzero" % vid})
if not EFFECTS.has(row.get("effect", "")):
errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]})
if not TRIGGERS.has(row.get("trigger", "")):
errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]})
if not PLACEMENTS.has(row.get("placement", "")):
errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]})
# 跨字段互锁(加载期致命——枚举语义一致性随首批类型定稿批 2026-09-19 立闸):
# 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数
if row.get("effect", "") == "flat_damage":
if row.get("trigger", "") != "on_open":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid})
if row.get("placement", "") != "closed_only":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid})
var ev: int = int(row.get("effect_value", 0))
if ev < 1 or ev > 9:
errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]})
# 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown
if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown":
errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid})
# 信息遮蔽型只允许落安全格(遮蔽对象是数字;雷格无数字可遮)——placement=any 时由读取方
# 呈现面按 kind=safe 自然短路,此处不设布置约束
return errs
@@ -4,8 +4,60 @@
[resource]
script = ExtResource("1")
rows = []
percent_of_cells = 5
per_level_bonus = 2
pack_max = 4
rows = [{
"variant_id": "thorn",
"display_name": "荆棘丛",
"unlock_level": 1,
"scope": "region",
"effect": "flat_damage",
"effect_value": 1,
"trigger": "on_open",
"placement": "closed_only",
"board_types": ["dungeon"],
"counter_items": []
}, {
"variant_id": "vine",
"display_name": "藤蔓缠绕",
"unlock_level": 1,
"scope": "region",
"effect": "hide_number",
"effect_value": 0,
"trigger": "on_number_shown",
"placement": "any",
"board_types": ["dungeon"],
"counter_items": []
}, {
"variant_id": "magma",
"display_name": "岩浆",
"unlock_level": 2,
"scope": "region",
"effect": "flat_damage",
"effect_value": 2,
"trigger": "on_open",
"placement": "closed_only",
"board_types": ["underdark"],
"counter_items": []
}, {
"variant_id": "ash",
"display_name": "烬覆",
"unlock_level": 2,
"scope": "region",
"effect": "hide_number",
"effect_value": 0,
"trigger": "on_number_shown",
"placement": "any",
"board_types": ["underdark"],
"counter_items": []
}]
; 地形变异 清单为空是合法态(地形变异 P0):零变体行 → 棋盘生成零布置,行为与不存在本系统时一致。
; 首批变异类型(岩浆/冰冻,机制未定)定稿时在本清单加一行声明即应生效,无需改代码。雾格已整体
; 移除(见 design/gdd/05-不做清单.md)——本表不再有 fog 行。
; 地形变异 首批类型定稿批(2026-09-19 用户拍板)——四行两轴×两关:
; 第一关 dungeon(平原/密林基线):荆棘丛(伤害型:挖开扣 1 点生命,轮末结算兑现)+ 藤蔓缠绕
; (信息型:挖开且显示数字时数字被遮蔽,呈现层读取);第二关 underdark(幽暗地域):岩浆
; (伤害型 2 点,登记名沿用)+ 烬覆(信息型)。unlock_level 门控 + board_types 交集门控双闸,
; 首关候选池恒 {荆棘丛, 藤蔓缠绕},第二关候选池恒 {岩浆, 烬覆}。
; counter_items 恒空:反制道具配套义务随框架契约保留,兑现归 Alpha 反制批(2026-09-19 用户拍板
; 反制道具改排 Alpha——切片期变异暂无反制手段,纯税状态已知悉接受)。
; 全部行值 provisional 归 难度曲线与Boss 调参批(effect_value/布置参数均为加法核小整数轴)。
@@ -99,6 +99,8 @@ func _build_ui() -> void:
# 奖励徽章数据源:地图战斗资源 身份解析只读回调(已开格身份 icon——挖掘期 Active 相位内有效)
board_view.set_identity_resolver(_identity_lookup)
# 数字遮蔽数据源:地形变异 hide_number 解析只读回调(信息型呈现——board_view 渲染面读取)
board_view.set_variant_mask_resolver(_variant_mask_lookup)
logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
@@ -329,6 +331,20 @@ func _identity_lookup(c: Vector2i):
return settlement.query_reward_identity(c)
# 地形变异 数字遮蔽解析(信息型 hide_number——格带该效果变异即遮蔽;读取方按格子变异数据行动)
func _variant_mask_lookup(c: Vector2i) -> bool:
if settlement == null:
return false
var b = settlement.minesweeper.current_board()
if b == null:
return false
for vid in b.variant_ids(c):
var vrow: Dictionary = settlement.db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
func _show_board_fx_for_node(node_type: String = "") -> void:
# Boss 遭遇变体:紫赤影子爬棋盘一次性占位演出(§2.6)
if node_type == "boss":
@@ -40,6 +40,7 @@ var _marker := {} # Vector2i → {"dot": ColorRect, "badge": Label, "h
var _board: BoardData = null
var _minesweeper = null # 排雷玩法 只读查询引用(refresh 更新;null = 无战斗)
var _identity_cb := Callable() # 地图战斗资源 身份解析回调(组合根注入只读面)——Vector2i → Dictionary/null
var _mask_cb := Callable() # 地形变异 数字遮蔽回调(组合根注入只读面)——Vector2i → bool(true = 该格数字不显示)
var _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
var _breath_t := 0.0
var _dig_glow := false # 挖掘期才允许唯一在动元素(art-bible §2.1)
@@ -92,6 +93,12 @@ func set_identity_resolver(cb: Callable) -> void:
_identity_cb = cb
# 组合根注入 地形变异 数字遮蔽只读回调(信息型 hide_number 触发面——触发枚举 on_number_shown:
# 已开且显示数字时起作用;读取方按格子变异数据行动,规则本体零特判)
func set_variant_mask_resolver(cb: Callable) -> void:
_mask_cb = cb
# D 区可用面积(AP 条之外)→ 格边长 = floor((min(宽, 高) − 预留 − (N−1)×格间距) / N)(battle.md 格尺寸公式)
# 2026-09-19 修复:格边长公式补算 (N−1)×2px 网格间距 + 垂直预留 24px——旧公式只算 N×cell,
# L2 12×12 板网格实高溢出可用区 ~6px,下缘被 clip_contents 裁掉约 1/6 格高。
@@ -313,7 +320,10 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
bg = COL_FLOOR
var d: int = rec.get("digit", 0)
if d > 0:
text = str(d)
if _mask_cb.is_valid() and bool(_mask_cb.call(c)):
text = "?" # 数字遮蔽(地形变异 hide_number——信息型:该格真实数字不显示)
else:
text = str(d)
else:
if flag:
bg = COL_BRICK
@@ -0,0 +1 @@
uid://cc2yhv5d20gfk
@@ -68,6 +68,8 @@ func _run_core_smoke(db, rng) -> void:
_smoke_board_generator()
_smoke_layout_cell_formula()
_smoke_variation_framework()
_smoke_variant_placement(1)
_smoke_variant_placement(2)
_smoke_full_battle(1, 0, "L1 · 地精")
_smoke_full_battle(2, 0, "L2 · 兄弟精")
_smoke_level()
@@ -484,7 +486,7 @@ func _smoke_board_generator() -> void:
# 但必须打印实测值(正常出口);accept_worst 由下方 illegal_product 断言承担。
check(inf >= float(row.inferability_target) or mode == "accept_best",
"L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode])
check(b1.variants.is_empty(), "L%d 零变体格(地形变异 清单为空 → 生成期零布置)" % L)
check(b1.variants.is_empty(), "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L)
check(b1.metrics.has("degraded_product") and b1.metrics.has("target_mines"), "L%d 指标集输出降级标记与目标雷数(三分出口表)" % L)
check(b1.degraded_product == (mode == "accept_best"), "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)])
check(b1.illegal_product == (mode == "accept_worst"), "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)])
@@ -506,13 +508,131 @@ func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
return rg
# ---- 地形变异 框架面(P0 空清单合法 + 候选池门控通道在位——框架惰性非废弃)----
# ---- 地形变异 框架面(首批类型定稿批 2026-09-19:四行两轴×两关 + 门控 + 跨字段互锁)----
func _smoke_variation_framework() -> void:
print("--- 地形变异 框架面(空清单合法 + 候选池门控通道)---")
check(_db.variation.rows.is_empty(), "变异清单为空(P0 合法态——零变体格)")
check(_db.variant_pool(1, "dungeon").is_empty(), "候选池为空——棋盘生成静默产出无变异图(不报错、不警告)")
print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---")
check(_db.variation.rows.size() == 4, "变异清单 4 行(荆棘丛/藤蔓缠绕/岩浆/烬覆——首批类型定稿批)")
check(_db.variation.validate().is_empty(), "变异表校验零错误(4 行全过域与跨字段互锁)")
var pool1_ids: Array = []
for r in _db.variant_pool(1, "dungeon"):
pool1_ids.append(String(r.variant_id))
check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)")
var pool2_ids: Array = []
for r in _db.variant_pool(2, "underdark"):
pool2_ids.append(String(r.variant_id))
check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)")
check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)")
var pool2d_ids: Array = []
for r in _db.variant_pool(2, "dungeon"):
pool2d_ids.append(String(r.variant_id))
check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)")
check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)")
# 跨字段互锁负样本(加载期致命——枚举语义一致性闸)
var vt = load("res://data/schemas/variation_table.gd").new()
vt.rows = [{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region",
"effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}]
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)")
vt.rows = [{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region",
"effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}]
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒")
func _errs_has_cross_field(errs: Array) -> bool:
for e in errs:
if String(e.get("category", "")) == "cross_field":
return true
return false
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步断言面)----
# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异——
# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。
func _smoke_variant_placement(L: int) -> void:
print("--- 地形变异 布置段(L%d)---" % L)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}):
check(false, "L%d 布置段战斗启动失败" % L)
return
var b = settlement.minesweeper.current_board()
check(b != null, "L%d 布置段板非空" % L)
if b == null:
return
var pool_ids: Array = []
for r in _db.variant_pool(L, String(b.board_type)):
pool_ids.append(String(r.variant_id))
var vcells: Array = b.variants.keys()
var vid0 := ""
var single_type := true
var bad_id := false
for c in vcells:
var ids: Array = b.variants[c]
if ids.size() != 1:
single_type = false
for vid in ids:
if vid0 == "":
vid0 = String(vid)
elif vid0 != String(vid):
single_type = false
if not pool_ids.has(String(vid)):
bad_id = true
check(not bad_id, "L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)])
check(single_type, "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L)
var r0 := {}
for c in b.compute_r0():
r0[c] = true
var domain_ok := true
for c in vcells:
if r0.has(c) or b.is_mine(c):
domain_ok = false
check(domain_ok, "L%d 变异格全在初始展开区外且不吃雷格(布置域结构保障——closed_only)" % L)
var expect_cap: int = int(round(float(b.width * b.height) * float(_db.variation.percent_of_cells) / 100.0)) \
+ (L - 1) * int(_db.variation.per_level_bonus)
check(vcells.size() <= expect_cap, "L%d 变异格数 %d ≤ 公式上限 %d(图规模百分比 + 关卡递增)" % [L, vcells.size(), expect_cap])
check(vcells.size() > 0, "L%d 种子 42 板非零布置(候选池非空下应有遭遇面)" % L)
var pack_max: int = int(_db.variation.pack_max)
var vset := {}
for c in vcells:
vset[c] = true
var seen := {}
var pack_ok := true
for c in vcells:
if seen.has(c):
continue
seen[c] = true
var comp_size := 1
var q: Array = [c]
while q.size() > 0:
var x: Vector2i = q.pop_front()
for n in b.neighbors8(x):
if vset.has(n) and not seen.has(n):
seen[n] = true
comp_size += 1
q.append(n)
if comp_size > pack_max:
pack_ok = false
check(pack_ok, "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max])
# 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性)
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(42)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp})
var b2 = s2.minesweeper.current_board()
check(b2 != null and _variant_dump(b2) == _variant_dump(b), "L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L)
func _variant_dump(b) -> String:
var keys: Array = b.variants.keys()
keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x)
var parts: Array = []
for c in keys:
parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])])
return "|".join(parts)
func _board_dump(b) -> String:
@@ -106,8 +106,17 @@ func _ready() -> void:
await get_tree().process_frame
if main.board_view.board() == null:
problems.append("L2 单场未启动(board=null)")
elif not main.board_view.board().variants.is_empty():
problems.append("L2 板出现变体格(地形变异 清单为空 → 零布置)")
else:
# 地形变异 首批类型定稿批(2026-09-19):L2 板允许带变异(岩浆/烬覆)——布置域断言 =
# 变异格全在初始展开区外(closed_only 结构保障)
var b0 = main.board_view.board()
var r0p := {}
for c in b0.compute_r0():
r0p[c] = true
for c in b0.variants.keys():
if r0p.has(c):
problems.append("L2 板变异格落在初始展开区(地形变异 布置域违例)")
break
main.board_view.set_desaturated(true)
main.board_view.set_desaturated(false)
if problems.is_empty():