444 lines
18 KiB
GDScript
444 lines
18 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
|
||
# Date: 2026-09-10
|
||
# ConfigDB autoload(数据配置)——数值表单点访问入口(Rule 2)+ 校验双通道(Rule 6)+ R1 行解析。
|
||
# 实现 GDD:design/archive/gdd-2026-09-14/d2-data-config.md(系统 ID D2·数据配置——registry 追溯锚,命名规范见 CLAUDE.md)
|
||
# 切片注:headless --script 模式下由冒烟脚本显式实例化(数据配置 AC 前言);玩法代码禁止绕过本单点自建 load()。
|
||
extends Node
|
||
|
||
const SCHEMA_VERSION := 1
|
||
|
||
const TABLE_MANIFEST := {
|
||
"action_economy": "action_economy.tres",
|
||
"variation": "variation.tres",
|
||
"status_effects": "status_effects.tres",
|
||
"board_gen": "board_gen.tres",
|
||
"enemy": "enemy.tres",
|
||
"skill": "skills.tres",
|
||
"hero": "hero.tres",
|
||
"reward": "reward.tres",
|
||
"settlement": "settlement.tres",
|
||
"level": "level.tres",
|
||
"difficulty": "difficulty.tres",
|
||
"shop": "shop.tres", # GP+商店房实施批 2026-09-21(表与 schema 落籍归并行数据面——本批只接线加载与消费)
|
||
"meta": "meta.tres", # 元进度实现批 2026-09-21(MetaTable 四行族——schema 与表同批落籍)
|
||
"adventure": "adventure.tres", # 冒险结构实现批 2026-09-21(AdventureTable 建表——大奖励与钥石行值单点)
|
||
}
|
||
|
||
var action_economy: ActionEconomyTable
|
||
var variation: VariationTable
|
||
var status_effects: StatusEffectTable
|
||
var board_gen: BoardGenTable
|
||
var enemy: EnemyTable
|
||
var skill: SkillTable
|
||
var hero: HeroTable
|
||
var reward: RewardTable
|
||
var settlement: SettlementTable
|
||
var level: LevelTable
|
||
var difficulty: DifficultyTable
|
||
# GP+商店房实施批(2026-09-21):ShopTable 暂持无类型注记——schema class_name 落籍归并行数据面,
|
||
# 落籍后可收紧为 `var shop: ShopTable`(消费面一律走下方 shop_* 访问器,禁直读行集)
|
||
var shop = null
|
||
# 元进度实现批(2026-09-21):MetaTable 类型化持用(schema 与表同批落籍,无 shop 先例的落籍间隙问题)
|
||
var meta: MetaTable
|
||
var adventure: AdventureTable
|
||
|
||
var is_ready := false
|
||
var _diagnostics: Array = [] # {severity, category, message}
|
||
var _warn_dedup: Dictionary = {} # R1 四元组去重注册表
|
||
var _reload_gate := false # 数据配置 EC8——战斗结算 唯一调用方
|
||
|
||
|
||
func push_diag(severity: String, category: String, message: String) -> void:
|
||
_diagnostics.append({"severity": severity, "category": category, "message": message})
|
||
if severity == "warning":
|
||
push_warning("[ConfigDB/%s] %s" % [category, message])
|
||
else:
|
||
push_error("[ConfigDB/%s] %s" % [category, message])
|
||
|
||
|
||
# 运行期数据致命(2026-09-18 用户拍板:越域记 Fatal 级日志后立即退出——数据配置通道 fail-fast 纪律补齐到查询期)
|
||
func _fatal(category: String, message: String) -> void:
|
||
push_diag("fatal", category, message)
|
||
push_error("[ConfigDB/%s] FATAL: %s —— 数据配置致命,进程退出" % [category, message])
|
||
var main_loop := Engine.get_main_loop()
|
||
if main_loop is SceneTree:
|
||
main_loop.quit(1) # 帧末退出,退出码 1——调用方须立即 return,勿再消费返回值
|
||
else:
|
||
OS.crash("ConfigDB/%s fatal: %s" % [category, message])
|
||
|
||
|
||
func reset_diagnostics() -> void:
|
||
_diagnostics.clear()
|
||
_warn_dedup.clear()
|
||
|
||
|
||
func diagnostics() -> Array:
|
||
return _diagnostics.duplicate()
|
||
|
||
|
||
func diagnostics_count(severity: String) -> int:
|
||
var n := 0
|
||
for d in _diagnostics:
|
||
if d.severity == severity:
|
||
n += 1
|
||
return n
|
||
|
||
|
||
func _ready() -> void:
|
||
load_tables("res://data/tables", false)
|
||
|
||
|
||
func load_tables(root: String, collect_only: bool) -> bool:
|
||
is_ready = false
|
||
_diagnostics.clear()
|
||
_warn_dedup.clear()
|
||
var loaded := {}
|
||
for key in TABLE_MANIFEST.keys():
|
||
var path := root.path_join(TABLE_MANIFEST[key])
|
||
if not ResourceLoader.exists(path):
|
||
push_diag("error", "missing_file", "表文件缺失: %s" % path)
|
||
continue
|
||
var res: Resource = load(path)
|
||
if res == null:
|
||
push_diag("error", "load_failed", "表加载失败: %s" % path)
|
||
continue
|
||
loaded[key] = res
|
||
for e in res.validate():
|
||
push_diag("error", e.category, "%s → %s" % [key, e.message])
|
||
# 跨表校验(悬空引用 / 池护栏——全部加载期致命)
|
||
_cross_validate(loaded)
|
||
var has_errors := diagnostics_count("error") > 0
|
||
if has_errors and not collect_only:
|
||
# 游戏通道:致命(Rule 6)——is_ready 恒 false,调用方(主场景)中止构建
|
||
return false
|
||
action_economy = loaded.get("action_economy")
|
||
variation = loaded.get("variation")
|
||
status_effects = loaded.get("status_effects")
|
||
board_gen = loaded.get("board_gen")
|
||
enemy = loaded.get("enemy")
|
||
skill = loaded.get("skill")
|
||
hero = loaded.get("hero")
|
||
reward = loaded.get("reward")
|
||
settlement = loaded.get("settlement")
|
||
level = loaded.get("level")
|
||
difficulty = loaded.get("difficulty")
|
||
shop = loaded.get("shop")
|
||
meta = loaded.get("meta")
|
||
adventure = loaded.get("adventure")
|
||
is_ready = not has_errors
|
||
return not has_errors
|
||
|
||
|
||
func _cross_validate(loaded: Dictionary) -> void:
|
||
var st: StatusEffectTable = loaded.get("status_effects")
|
||
var rt: RewardTable = loaded.get("reward")
|
||
var st_tab: SettlementTable = loaded.get("settlement")
|
||
if st == null or rt == null or st_tab == null:
|
||
return # 单表校验已报错,不连锁
|
||
var effect_ids := {}
|
||
for row in st.rows:
|
||
effect_ids[row.get("effect_id", "")] = true
|
||
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
|
||
# GP+商店房实施批(2026-09-21)例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
|
||
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
|
||
for row in rt.rows:
|
||
var eff: String = row.get("effect_id", "")
|
||
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
|
||
push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
|
||
# ② SettlementTable.effect_pool_map 悬空
|
||
for eff in st_tab.effect_pool_map.keys():
|
||
if not effect_ids.has(eff):
|
||
push_diag("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
|
||
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, flag_miss}——2026-09-15 格面资源批删 item 键)
|
||
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "flag_miss"]
|
||
for s in required_sources:
|
||
if not st_tab.source_label_map.has(s):
|
||
push_diag("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
|
||
# ④ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
|
||
# 线性推进器按精确深度行跑,floor 兜底静默降难度属调参事故,加载期直接拦)
|
||
var lv: LevelTable = loaded.get("level")
|
||
var bg: BoardGenTable = loaded.get("board_gen")
|
||
var en: EnemyTable = loaded.get("enemy")
|
||
if lv != null and bg != null and en != null:
|
||
var bg_keys := {}
|
||
for row in bg.rows:
|
||
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
|
||
var en_keys := {}
|
||
for row in en.rows:
|
||
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
|
||
for lrow in lv.rows:
|
||
var li: int = int(lrow.level_index)
|
||
for node in lrow.nodes:
|
||
var t: String = String(node.get("type", ""))
|
||
if t != "battle" and t != "boss":
|
||
continue
|
||
var key := "%d_%d" % [li, int(node.get("node_depth", -1))]
|
||
if not bg_keys.has(key):
|
||
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
|
||
if not en_keys.has(key):
|
||
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 enemy 无精确行" % [li, t, key])
|
||
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
|
||
var skt = loaded.get("skill")
|
||
if skt != null and en != null:
|
||
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
|
||
push_diag("error", e.category, e.message)
|
||
|
||
|
||
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
|
||
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 +
|
||
# proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。
|
||
# status_table 显式入参:装载序上本校验先于成员赋值执行(self.status_effects 尚为 Nil)——装载通道传 loaded 表,
|
||
# 冒烟直验传 _db.status_effects;传 null 时 side 守卫跳过(其他校验不受影响)。
|
||
func validate_skill_pool_links(skill_table, enemy_table, status_table = null) -> Array:
|
||
var errs: Array = []
|
||
var skill_types := {}
|
||
for row in skill_table.rows:
|
||
skill_types[String(row.get("skill_id", ""))] = String(row.get("type", ""))
|
||
# 施放类 side 守卫(新效应词汇批 2026-09-19):buff → 敌方侧状态 / debuff → 主角侧状态,防反向挂接
|
||
for row in skill_table.rows:
|
||
var st: String = String(row.get("type", ""))
|
||
if st != "buff" and st != "debuff":
|
||
continue
|
||
var want_side: String = "enemy" if st == "buff" else "hero"
|
||
var eff: String = String(row.get("buff_ref", ""))
|
||
var srow: Dictionary = status_table.row_by_id(eff) if status_table != null else {}
|
||
if not srow.is_empty() and String(srow.get("target_side", "")) != want_side:
|
||
errs.append({"category": "cast_side", "message": "SkillTable %s(%s)引用 %s 的 target_side=%s ≠ %s(施放类 side 守卫)" % [String(row.get("skill_id", "")), st, eff, String(srow.get("target_side", "")), want_side]})
|
||
for row in enemy_table.rows:
|
||
var eid: String = String(row.get("enemy_id", ""))
|
||
var basic_n := 0
|
||
var buff_n := 0
|
||
var debuff_n := 0
|
||
for entry in row.get("pool", []):
|
||
var sid: String = String(entry.get("skill_id", ""))
|
||
if not skill_types.has(sid):
|
||
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
|
||
continue
|
||
match skill_types[sid]:
|
||
"basic":
|
||
basic_n += 1
|
||
"buff":
|
||
buff_n += 1
|
||
"debuff":
|
||
debuff_n += 1
|
||
if basic_n != 1:
|
||
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
|
||
if buff_n > 1:
|
||
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
|
||
if debuff_n > 1:
|
||
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]})
|
||
return errs
|
||
|
||
|
||
# ---- R1 难度行解析(2026-09-18 用户拍板修订:ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)----
|
||
|
||
func resolve_board_gen(L: int, D: int) -> Dictionary:
|
||
return _resolve_progress(board_gen.rows, L, D, "board_gen")
|
||
|
||
|
||
func resolve_enemy_rowset(L: int, D: int) -> Dictionary:
|
||
return _resolve_progress(enemy.rows, L, D, "enemy")
|
||
|
||
|
||
# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
|
||
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
|
||
if difficulty == null:
|
||
return {}
|
||
for row in difficulty.rows:
|
||
if int(row.get("level_index", 0)) == L and int(row.get("node_depth", -1)) == D:
|
||
return {
|
||
"hp_mult": clampf(float(row.get("hp_mult", 1.0)), 0.1, 10.0),
|
||
"atk_mult": clampf(float(row.get("atk_mult", 1.0)), 0.1, 10.0),
|
||
}
|
||
return {}
|
||
|
||
|
||
func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Dictionary:
|
||
if L < 1 or D < 0:
|
||
_fatal("request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D])
|
||
return {}
|
||
var level_keys: Array = []
|
||
for row in rows:
|
||
var lv := int(row.get("level_index", 0))
|
||
if not level_keys.has(lv):
|
||
level_keys.append(lv)
|
||
if level_keys.is_empty():
|
||
_fatal("structural", "%s 行集为空——锚行校验应已拦截" % table_id)
|
||
return {}
|
||
level_keys.sort()
|
||
var l_star := _ceil_key(level_keys, L)
|
||
var l_clamped := l_star < 0
|
||
if l_clamped:
|
||
l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(2026-09-18 追加拍板:可取范围内向上取整,超出用最大值)
|
||
var depth_keys: Array = []
|
||
for row in rows:
|
||
if int(row.get("level_index", 0)) == l_star:
|
||
var dv := int(row.get("node_depth", 0))
|
||
if not depth_keys.has(dv):
|
||
depth_keys.append(dv)
|
||
depth_keys.sort()
|
||
var d_star := _ceil_key(depth_keys, D)
|
||
var d_clamped := d_star < 0
|
||
if d_clamped:
|
||
d_star = int(depth_keys[-1])
|
||
var missing := ""
|
||
if l_clamped and d_clamped:
|
||
missing = "both_clamp"
|
||
elif l_clamped:
|
||
missing = "level_clamp"
|
||
elif d_clamped:
|
||
missing = "depth_clamp"
|
||
elif l_star != L and d_star != D:
|
||
missing = "both_ceil"
|
||
elif l_star != L:
|
||
missing = "level_ceil"
|
||
elif d_star != D:
|
||
missing = "depth_ceil"
|
||
if missing != "":
|
||
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
|
||
if not _warn_dedup.has(dedup_key):
|
||
_warn_dedup[dedup_key] = true
|
||
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
|
||
push_diag("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing])
|
||
for row in rows:
|
||
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
|
||
return row
|
||
return {}
|
||
|
||
|
||
# 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal
|
||
func _ceil_key(sorted_keys: Array, request: int) -> int:
|
||
for k in sorted_keys:
|
||
if int(k) >= request:
|
||
return int(k)
|
||
return -1
|
||
|
||
|
||
# ---- 地形变异 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("board_types", [])
|
||
if tags.is_empty():
|
||
pool.append(row)
|
||
continue
|
||
if tags.has(board_type):
|
||
pool.append(row)
|
||
return pool
|
||
|
||
|
||
func status_effect_row(effect_id: String) -> Dictionary:
|
||
return status_effects.row_by_id(effect_id)
|
||
|
||
|
||
# ---- GP+商店房实施批(2026-09-21):ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)----
|
||
# 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} ·
|
||
# level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value,
|
||
# base_price, price_increment} · price_level_bonus {level_index, bonus}。
|
||
# 行族经 ShopTable 同名数组属性读取(.get 容缺——表落籍前查询恒空,掉落按 0 兑付的兜底在调用方)。
|
||
|
||
func shop_gp_drop_row(tier: String) -> Dictionary:
|
||
for row in _shop_rows("gp_drops"):
|
||
if String(row.get("tier", "")) == tier:
|
||
return row
|
||
return {}
|
||
|
||
|
||
func shop_level_gp_bonus(level_index: int) -> int:
|
||
for row in _shop_rows("level_gp_bonus"):
|
||
if int(row.get("level_index", -1)) == level_index:
|
||
return int(row.get("bonus", 0))
|
||
return 0
|
||
|
||
|
||
func shop_price_level_bonus(level_index: int) -> int:
|
||
for row in _shop_rows("price_level_bonus"):
|
||
if int(row.get("level_index", -1)) == level_index:
|
||
return int(row.get("bonus", 0))
|
||
return 0
|
||
|
||
|
||
func shop_products() -> Array:
|
||
return _shop_rows("products")
|
||
|
||
|
||
func shop_product_row(product_id: String) -> Dictionary:
|
||
for row in _shop_rows("products"):
|
||
if String(row.get("product_id", "")) == product_id:
|
||
return row
|
||
return {}
|
||
|
||
|
||
# 行族数组读取(ShopTable 属性名 = 行族名;表未落籍/属性缺失 = 空集非错误态)
|
||
func _shop_rows(family: String) -> Array:
|
||
if shop == null:
|
||
return []
|
||
var arr = shop.get(family)
|
||
return arr if arr is Array else []
|
||
|
||
|
||
# ---- 元进度实现批(2026-09-21):MetaTable 消费面(行集解析单点——MetaProgression/呈现层禁自扫表)----
|
||
# 行族四轴(数据契约,行集归 meta.tres,数值全 provisional 归元进度设计批配读数):
|
||
# forge_products {attr, display_name, flat_value, base_price, price_ratio_pct} ·
|
||
# boss_keystone {level_index, first_kill, repeat_kill} ·
|
||
# workshop_items {item_id, display_name, clear_threshold} ·
|
||
# conversion {attr, per_level_shift_pct}。
|
||
|
||
func meta_forge_row(attr: String) -> Dictionary:
|
||
for row in _meta_rows("forge_products"):
|
||
if String(row.get("attr", "")) == attr:
|
||
return row
|
||
return {}
|
||
|
||
|
||
func meta_boss_keystone_row(level_index: int) -> Dictionary:
|
||
for row in _meta_rows("boss_keystone"):
|
||
if int(row.get("level_index", -1)) == level_index:
|
||
return row
|
||
return {}
|
||
|
||
|
||
func meta_workshop_items() -> Array:
|
||
return _meta_rows("workshop_items")
|
||
|
||
|
||
func meta_conversion_row(attr: String) -> Dictionary:
|
||
for row in _meta_rows("conversion"):
|
||
if String(row.get("attr", "")) == attr:
|
||
return row
|
||
return {}
|
||
|
||
|
||
# 行族数组读取(MetaTable 属性名 = 行族名;表缺失/属性缺失 = 空集非错误态)
|
||
func _meta_rows(family: String) -> Array:
|
||
if meta == null:
|
||
return []
|
||
var arr = meta.get(family)
|
||
return arr if arr is Array else []
|
||
|
||
|
||
# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面)
|
||
func skill_row(skill_id: String) -> Dictionary:
|
||
return skill.row_by_id(skill_id)
|
||
|
||
|
||
# ---- 数据配置 EC8:debug 热重载门(战斗结算 唯一调用方;切片实现 = 门标志 + 拒绝语义)----
|
||
|
||
func set_reload_gate(gated: bool) -> void:
|
||
_reload_gate = gated
|
||
|
||
|
||
func reload() -> bool:
|
||
if _reload_gate:
|
||
push_diag("warning", "reload_gated", "战斗中热重载被拒绝(数据配置 EC8)")
|
||
return false
|
||
var ok := load_tables("res://data/tables", false)
|
||
if ok:
|
||
push_diag("debug", "config_reloaded", "表热重载完成")
|
||
return ok
|