T1 schema 换代:MetaTreeTable FORK_GROUPS 4→3(fg_mastery 退役)/RULE_KEYS 2→6(sense/economy 四键扩签)/ fork 单行双态(effects_a/b+name_a/b、每组恰一校验)/row 字段退役(行门槛主语=节点等级 level_gate)/ requires 只留点亮前置(R1 裁撤案执行);meta_tree.tres 33→12 行重排(脊柱2/锻造2含彩蛋/钥石2/同调6, att_socket1 改根节点无前置,easter_ley 摘 atk/armor_class 分量=契约9) T2 购买与彩蛋面:tree_can_buy easter 拒购+行门槛换轴;tree_buy_fork=解锁默认A;tree_toggle_fork 零币切态; tree_rule_keys 按激活态 effects_a/b 投影;_check_easter_egg 特化判定(两线拉满+socket全解锁);营地树行板双态钮 T3 六键接线:EVENTS+flag_missed/FACTS+battle_index(L2 扩签两处);rebuild 六键合成订阅 (posture 窗=battle_index&&round_index 双闸/sense 两组=counter 闸+记账双行 domain:adventure 限次2次 provisional); level_runner battle_index 注入(visited battle 型计数);_judge_flag 去重闸后 flag_missed 发射(契约12); 聚=_roll_gp_drop 入账前×1.5 provisional(escrow 不乘);散=_spend_discounted ×80%(current_price/pool_price 双计价点,刷新价不折=§11②定义边界);_start_current_level 限次按关清零(_reset_tree_limited_counters 可测口) T4 注入换代:hero_override=树淬炼flat+FORGE_ROUTE_BONUS预留0(命中不注入=契约6);Attunement.slots 判定源切 树点亮数(谓词表退役);tree_buy(ks_refresh) 同账路由 buy_attune_refresh(V15 双入口) T5 旧档废弃与删除:save_manager tree_struct=2 结构版本标记+_normalize 白名单重建+树域废弃重置(禁迁移折算); migrate_tree/buy_forge/forge_flat/forge_level/forge_price/meta_forge_row/forge_products 行族/ attunement_growth 表+访问器/生涯页三投影/career forge_total 全删;treasure_item_table UNLOCK_COUNTER_KEYS 内联; verifier_batch 花费策略换代树节点;MC slots 画像常量3 T6 营地统一:四柜台并排横滚(树淬炼主界面/锻造工坊钥匙门+零交互占位面板/秘宝匣钥匙门+内容迁入/图鉴常开); 暂停层锻造段连带删;camp data forge 投影删 存量红修复:_flush_pending_reveals 三处空 source_class 补 fx_reveal+类级豁免(a57489e9 落地件,冒烟51行) 测试随批:smoke 树段全重写(V1/V2/V3/V8/V9/V16)+_smoke_tree_rules V10 行为段(窗/限次/守侧五场景编排rig/聚散 同种子对拍)+单元 tree_rules_v11_test(六键合成行闭集校验)+run_all 22 套;ui_probe/ui_click_probe 换代树点击路径 轻闸全绿:冒烟886/0+run_all 22套+probe --click 全绿
358 lines
16 KiB
GDScript
358 lines
16 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 钥石能否在营地换成永久强化并在下一局开局生效——冒险收束入账(大奖励 + Boss
|
||
# 首杀/重复杀两档)、营地消费、注入计算的跨局账本能否在零 UI 下独立成立(元进度 GDD 验收标准)
|
||
# Date: 2026-09-21(元进度实现批——设计权威 design/gdd/systems/冒险循环/元进度.md 定稿 v1.2)
|
||
# 元进度账本服务(Core 层零 UI、纯被动服务——同 StatusContainer 先例:零信号零外发,读存档算账)。
|
||
# 职责三件:① 属性锻造定价与购买(几何递增——tier 级价格 = 迭代 floor(p × ratio/100),tier 0 =
|
||
# base_price,floor 单一取整对齐 round_direction 纪律);② 道具工坊解锁(渠道由 meta.tres channel
|
||
# 字段声明、判定量换算单点 _workshop_progress——2026-09-21 用户拍板可替换轴;首版 = 总通关次数,
|
||
# 钥石直购价随定价批后补最终双渠道;六件局内效果归 Alpha 秘宝重设计批,本类只有解锁状态);
|
||
# ③ 冒险收束入账(Boss 两档 = 表值 first_kill/repeat_kill,首杀标记读存档判first;调 SaveManager
|
||
# apply_adventure_summary 入账并附 boss_drops 副本——复盘可归因,验收 5)。
|
||
# 注入面:hero_override() = 三档 forge flat(全零 = 白板合法态;override 不写表——表运行期只读,
|
||
# 消费方 AdventureRunner/LevelRunner 经冒险开始参数面显式传参)。
|
||
# 纪律:数值零字面量——价格/档位/阈值/两档金额只读 db.meta_* 访问器;存档只经 SaveManager 访问器,
|
||
# 禁读内部结构;行集解析禁自扫表(一律走 ConfigDB 访问器)。
|
||
class_name MetaProgression
|
||
extends RefCounted
|
||
|
||
var db: ConfigDb
|
||
var save: SaveManager
|
||
|
||
|
||
func _init(db_ref: ConfigDb, save_ref: SaveManager) -> void:
|
||
db = db_ref
|
||
save = save_ref
|
||
|
||
|
||
func keystone_balance() -> int:
|
||
return save.keystone_balance()
|
||
|
||
|
||
# ---- 道具工坊(首版全走通关次数渠道——钥石直购价随定价批后补,最终双渠道并存)----
|
||
# 〔属性锻造柜台退役——迭代 21 T4/T5:buy_forge/forge_flat/forge_level/forge_price 与 meta_forge
|
||
# 表族随攻防所有权切分删除;生命唯一入口=树脊柱淬炼、攻/AC 唯一入口=锻造工坊路线(契约 9);
|
||
# 注入面 hero_override 换树淬炼 flat+锻造路线加值位(预留 0)公式。〕
|
||
|
||
# 解锁判定量单点(可替换轴——2026-09-21 用户拍板:解锁条件须抽象、可替换、可扩展,禁与渠道实现耦合)。
|
||
# 契约:渠道由 meta.tres workshop_items.channel 声明(缺省 total_clears,词表 MetaTable.WORKSHOP_CHANNELS
|
||
# 封闭);本方法是渠道 → 判定数值的唯一换算点——换/加渠道 = 词表加值 + 此处增一 match 分支,
|
||
# 阈值比较(refresh/workshop_status)与解锁写档零改动。候选渠道(语义已预留未实现):
|
||
# adventure_depth 最高通关深度 / keystone 钥石直购双渠道。
|
||
func _workshop_progress(channel: String) -> int:
|
||
match channel:
|
||
"total_clears":
|
||
var total := 0
|
||
for i in db.level_count(): # 表访问收口(2026-10-02——行集直扫退役)
|
||
total += save.clear_count(i + 1)
|
||
return total
|
||
_:
|
||
return 0 # 未知渠道恒 0 = 永不解锁(fail-safe 非致命——渠道词表校验兜底,不静默误解锁)
|
||
|
||
|
||
# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager)
|
||
func is_item_unlocked(item_id: String) -> bool:
|
||
return save.item_unlocked(item_id)
|
||
|
||
|
||
# 工坊六行只读投影(呈现层数据源):{item_id, display_name, threshold, unlocked, progress}
|
||
func workshop_status() -> Array:
|
||
var out: Array[Dictionary] = []
|
||
for row in db.meta_workshop_items():
|
||
var iid := String(row.get("item_id", ""))
|
||
var arow: Dictionary = db.treasure_item_row(iid) # 迭代 7 T5——失义/退役状态面(呈现「能力封存/已退役」)
|
||
out.append({
|
||
"item_id": iid,
|
||
"display_name": String(row.get("display_name", "")),
|
||
"threshold": int(row.get("clear_threshold", 0)),
|
||
"unlocked": save.item_unlocked(iid),
|
||
"progress": _workshop_progress(String(row.get("channel", "total_clears"))),
|
||
"deferred": bool(arow.get("deferred", false)),
|
||
"retired": bool(arow.get("retired", false)),
|
||
})
|
||
return out
|
||
|
||
|
||
# 达标未解锁 → unlock_item + 写盘;返回新解锁 item_id 列(冒险收束后与营地打开时各调一次)
|
||
func refresh_workshop_unlocks() -> Array:
|
||
var new_unlocks: Array[String] = []
|
||
for row in db.meta_workshop_items():
|
||
var iid := String(row.get("item_id", ""))
|
||
if save.item_unlocked(iid):
|
||
continue
|
||
var progress := _workshop_progress(String(row.get("channel", "total_clears")))
|
||
if progress >= int(row.get("clear_threshold", 99)):
|
||
save.unlock_item(iid)
|
||
new_unlocks.append(iid)
|
||
if not new_unlocks.is_empty():
|
||
save.write_to_disk()
|
||
return new_unlocks
|
||
|
||
|
||
# ---- Boss 两档与收束入账(结算屏文案数据源)----
|
||
# 明细 {big_reward, boss_drops, total, new_unlocks};入账与标记经 save.apply_adventure_summary
|
||
# (keystone_drops 副本随摘要入 recent_summaries)。
|
||
# 迭代 10 奖励重构批:钥石改 Boss 逐杀掉落——掉落金额已在冒险内逐杀入账(summary.keystone_total
|
||
# 含全部掉落;首杀/复杀裁决在 AdventureRunner._boss_keystone_drop,首杀标记仍在下方
|
||
# apply_adventure_summary 逐通关关次置位)。本函数不再派生掉落金额,只做标记+入账+归档。
|
||
func settle_adventure(summary: Dictionary) -> Dictionary:
|
||
# 首次冒险布尔位置位(关卡节点图生成 v0.4——完成/失败/放弃三结局同经本收束挂点,写入只在收束)
|
||
save.mark_first_adventure_done()
|
||
var drops: Array = (summary.get("keystone_drops", []) as Array).duplicate(true)
|
||
save.apply_adventure_summary(summary, drops)
|
||
var new_unlocks := refresh_workshop_unlocks()
|
||
save.write_to_disk()
|
||
var total := int(summary.get("keystone_total", 0))
|
||
return {
|
||
"big_reward": total,
|
||
"boss_drops": drops,
|
||
"total": total,
|
||
"new_unlocks": new_unlocks,
|
||
}
|
||
|
||
|
||
# ---- 跨局注入(冒险开始参数面)----
|
||
# 迭代 21 T4 注入公式换代:基础值 + 树生命/命中淬炼 flat(命中线在账未注入——契约 6,消费面归
|
||
# 数值批)+ 锻造路线加值位(本批预留 0——锻造工坊实装批按当前武器/甲路线等级填充)+ 局内加算区
|
||
# (bonus 区域不经本面)。旧属性柜台 forge_flat 项退役移除(V7/V12);攻/AC 树来源随所有权切分
|
||
# 砍除(契约 9——树零攻防来源,现表零 atk/armor_class 行,tree_stat_flat 二轴恒 0 保留通式)。
|
||
# 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。
|
||
const FORGE_ROUTE_BONUS := {"atk": 0, "max_hp": 0, "armor_class": 0} # 锻造路线加值位(预留 0——锻造工坊实装批填充)
|
||
|
||
func hero_override() -> Dictionary:
|
||
return {
|
||
"atk": int(FORGE_ROUTE_BONUS.get("atk", 0)) + tree_stat_flat("atk"),
|
||
"max_hp": int(FORGE_ROUTE_BONUS.get("max_hp", 0)) + tree_stat_flat("max_hp"),
|
||
"armor_class": int(FORGE_ROUTE_BONUS.get("armor_class", 0)) + tree_stat_flat("armor_class"),
|
||
}
|
||
|
||
|
||
# ---- 天赋树段(迭代 12 T8——R1 甲融合案;设计权威 整合定稿 §1.4 一树双币三脉一脊)----
|
||
|
||
# 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」)
|
||
func tree_spent_total() -> int:
|
||
var total := 0
|
||
for lvl in (save.tree_nodes() as Dictionary).values():
|
||
total += int(lvl)
|
||
return total
|
||
|
||
|
||
# 节点可购校验(行门槛 + 显式前置 + 币 + cap;free 节点=钥匙不经购买,彩蛋=觉醒不经购买)
|
||
func tree_can_buy(node_id: String) -> bool:
|
||
var row: Dictionary = db.tree_node(node_id)
|
||
if row.is_empty():
|
||
return false
|
||
# 彩蛋不经购买(觉醒唯一入口=_check_easter_egg 特化判定——V8 反例:手动购亮=红)
|
||
if String(row.get("kind", "")) == "easter":
|
||
return false
|
||
var lvl := save.tree_level(node_id)
|
||
if lvl >= int(row.get("cap", 1)):
|
||
return false
|
||
# 刷新升级节点:cap 主语=Y 账本(单一事实源——树上入口与装配段同 cap 同价,防双账漂移)
|
||
if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.attune_refresh_level() + 1).is_empty():
|
||
return false
|
||
# 行门槛(一线一节点:脊柱节点第 N 级需全树总投入 ≥4×(N−1)——迭代 21 主语平移,row 字段退役)
|
||
if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat":
|
||
if tree_spent_total() < db.meta_tree.level_gate(lvl + 1):
|
||
return false
|
||
for req in (row.get("requires", []) as Array):
|
||
if save.tree_level(String(req)) < 1:
|
||
return false
|
||
return true
|
||
|
||
|
||
# 节点购买(营地树 UI 唯一写口):树全钥石单币(2026-10-07 用户裁甲)。free=工作间钥匙零币点亮
|
||
# (price 0,零额消费无副作用);彩蛋不经购买(can_buy 已拒——觉醒唯一入口=_check_easter_egg)。
|
||
# 刷新升级节点走同账路由(Q1 裁乙——树上+秘宝匣装配段双入口写同一 attune_refresh_level 账本,
|
||
# 价格/上限单一事实源=Y 行表值 keystone_cost;V15)。
|
||
func tree_buy(node_id: String) -> bool:
|
||
if node_id == "ks_refresh":
|
||
return buy_attune_refresh()
|
||
var row: Dictionary = db.tree_node(node_id)
|
||
if row.is_empty() or not tree_can_buy(node_id):
|
||
return false
|
||
var price := int(row.get("price", 0))
|
||
if not save.spend_keystone(price):
|
||
return false
|
||
save.set_tree_level(node_id, save.tree_level(node_id) + 1)
|
||
_check_easter_egg()
|
||
return save.write_to_disk()
|
||
|
||
|
||
# Fork 切换(营地树 UI——一节点双态:购买解锁组默认 A 态,换态走本口零币;同态重复=零操作;
|
||
# 未解锁组切换=拒绝——解锁唯一入口=购买面 tree_buy_fork)
|
||
func tree_toggle_fork(group: String, side: String) -> bool:
|
||
if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"):
|
||
return false
|
||
var cur := save.fork_side(group)
|
||
if cur == "" or cur == side:
|
||
return false # 未解锁走购买面;同态零操作
|
||
save.set_fork_side(group, side)
|
||
return save.write_to_disk()
|
||
|
||
|
||
# Fork 节点购买入口(一节点双态——购=解锁开关并置默认 A 态;重复购买由 cap 经 can_buy 拦截)
|
||
func tree_buy_fork(node_id: String) -> bool:
|
||
var row: Dictionary = db.tree_node(node_id)
|
||
if row.is_empty() or String(row.get("kind", "")) != "fork":
|
||
return tree_buy(node_id)
|
||
if not tree_can_buy(node_id):
|
||
return false
|
||
var price := int(row.get("price", 0))
|
||
if not save.spend_keystone(price):
|
||
return false
|
||
save.set_tree_level(node_id, 1)
|
||
save.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态
|
||
return save.write_to_disk()
|
||
|
||
|
||
# 树效果投影:四线 flat 汇总 + rule 键集(激活 Fork 侧 effects 并入)
|
||
func tree_stat_flat(key: String) -> int:
|
||
var total := 0
|
||
for row in db.tree_rows():
|
||
var lvl := save.tree_level(String(row.get("node_id", "")))
|
||
if lvl <= 0:
|
||
continue
|
||
total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl
|
||
return total
|
||
|
||
|
||
func tree_rule_keys() -> Array:
|
||
var out: Array = []
|
||
var sides: Dictionary = save.fork_sides()
|
||
for row in db.tree_rows():
|
||
if String(row.get("kind", "")) != "fork":
|
||
continue
|
||
var active := String(sides.get(String(row.get("fork_group", "")), ""))
|
||
if active == "":
|
||
continue # 未解锁组零规则输出
|
||
var fx: Dictionary = row.get("effects_" + active, {}) as Dictionary
|
||
for k in fx.keys():
|
||
if not out.has(String(k)):
|
||
out.append(String(k))
|
||
return out
|
||
|
||
|
||
# 营地树面投影(CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工)
|
||
func tree_camp_rows() -> Array:
|
||
var out: Array = []
|
||
for row in db.tree_rows():
|
||
if String(row.get("kind", "")) == "fork":
|
||
continue # Fork 行由 fork_groups 段承载
|
||
out.append({
|
||
"node_id": String(row.get("node_id", "")),
|
||
"display_name": String(row.get("display_name", "")),
|
||
"vein": String(row.get("vein", "")),
|
||
"kind": String(row.get("kind", "")),
|
||
"cap": int(row.get("cap", 1)),
|
||
"level": save.attune_refresh_level() if String(row.get("node_id", "")) == "ks_refresh" else save.tree_level(String(row.get("node_id", ""))), # 刷新升级行主语=Y 账本(同账路由 V15)
|
||
"price": int(row.get("price", 0)),
|
||
"currency": String(row.get("currency", "keystone")),
|
||
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
|
||
})
|
||
return out
|
||
|
||
|
||
func tree_fork_groups() -> Array:
|
||
var out: Array = []
|
||
var sides: Dictionary = save.fork_sides()
|
||
for row in db.tree_rows():
|
||
if String(row.get("kind", "")) != "fork":
|
||
continue
|
||
var fg := String(row.get("fork_group", ""))
|
||
out.append({
|
||
"group": fg,
|
||
"node_id": String(row.get("node_id", "")),
|
||
"display_name": String(row.get("display_name", "")),
|
||
"active_side": String(sides.get(fg, "")),
|
||
"owned": save.tree_level(String(row.get("node_id", ""))) > 0,
|
||
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
|
||
"price": int(row.get("price", 0)),
|
||
"sides": [
|
||
{"side": "a", "display_name": String(row.get("name_a", "")), "effects": row.get("effects_a", {}) as Dictionary},
|
||
{"side": "b", "display_name": String(row.get("name_b", "")), "effects": row.get("effects_b", {}) as Dictionary},
|
||
],
|
||
})
|
||
return out
|
||
|
||
|
||
# 彩蛋觉醒检查(特化判定——生命/命中两线拉满 且 同调位 1/2/3 全解锁 → easter_ley 免费点亮;
|
||
# 迭代 21 T2 换代:不再依赖 requires 点亮形态〔R1 裁撤〕,发现率监控呈报面沿袭)
|
||
func _check_easter_egg() -> void:
|
||
if save.tree_level("easter_ley") > 0:
|
||
return
|
||
for nid in ["sp_hp", "sp_hit"]:
|
||
var row: Dictionary = db.tree_node(nid)
|
||
if row.is_empty() or save.tree_level(nid) < int(row.get("cap", 1)):
|
||
return
|
||
for i in range(1, 4):
|
||
if save.tree_level("att_socket%d" % i) < 1:
|
||
return
|
||
save.set_tree_level("easter_ley", 1)
|
||
|
||
|
||
# 三体系迁移函数已删除(迭代 21 T5——旧档破坏性废弃〔Q3 用户裁:开发阶段无迁移〕;换代前结构
|
||
# 由 SaveManager._normalize 的 tree_struct 标记检测废弃重置,禁迁移禁折算)。
|
||
|
||
|
||
# ---- 同调位账本(迭代 11 第①批 T4——装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2 + 需求卡片 P1 甲)----
|
||
|
||
# 装配切换(营地装配覆盖层唯一写口):已装 → 卸下恒可;未装 → 非同调行拒绝 + 满位禁选;
|
||
# 装配关系写存档持久(冒险中途不可换装——底稿 §8.5,本口只服务营地)。
|
||
func toggle_attunement(item_id: String) -> bool:
|
||
var equipped: Array = save.attunement_equipped()
|
||
if not equipped.has(item_id):
|
||
var arow: Dictionary = db.treasure_item_row(item_id)
|
||
if arow.is_empty() or String(arow.get("form", "")) == "":
|
||
return false # 非同调行(过渡/退役行)禁装配
|
||
if equipped.size() >= Attunement.slots(db, save):
|
||
return false # 满位禁选
|
||
equipped.append(item_id)
|
||
else:
|
||
equipped.erase(item_id)
|
||
save.set_attunement_equipped(equipped)
|
||
return save.write_to_disk()
|
||
|
||
|
||
# 营地装配面投影(账面归本类、拼句归 CampOverlay——_camp_data 同款分工)
|
||
func attune_camp_status() -> Dictionary:
|
||
var equipped: Array = save.attunement_equipped()
|
||
var items: Array[Dictionary] = []
|
||
for row in db.treasure_attunement_rows():
|
||
var iid := String(row.get("item_id", ""))
|
||
items.append({
|
||
"item_id": iid,
|
||
"display_name": String(row.get("display_name", "")),
|
||
"form": String(row.get("form", "")),
|
||
"equipped": equipped.has(iid),
|
||
})
|
||
var lvl := save.attune_refresh_level()
|
||
var next_row: Dictionary = db.meta_attune_refresh_row(lvl + 1)
|
||
return {
|
||
"slots": Attunement.slots(db, save),
|
||
"equipped": equipped,
|
||
"items": items,
|
||
"refresh": {
|
||
"level": lvl,
|
||
"charges": attune_refresh_charges(),
|
||
"price": int(next_row.get("keystone_cost", 0)),
|
||
"maxed": next_row.is_empty(),
|
||
},
|
||
}
|
||
|
||
|
||
# 每冒险开局刷新次数 Y(当前等级行表值;0 级 = 0 次即无刷新能力——P1 甲初始 0 同构)
|
||
func attune_refresh_charges() -> int:
|
||
return int(db.meta_attune_refresh_row(save.attune_refresh_level()).get("refresh_charges", 0))
|
||
|
||
|
||
# Y 升级购买(直购价 = 下一级行表值 keystone_cost;已满级/余额不足 false 零副作用——buy_forge 同款)
|
||
func buy_attune_refresh() -> bool:
|
||
var next_row: Dictionary = db.meta_attune_refresh_row(save.attune_refresh_level() + 1)
|
||
if next_row.is_empty():
|
||
return false
|
||
if not save.spend_keystone(int(next_row.get("keystone_cost", 0))):
|
||
return false
|
||
save.set_attune_refresh_level(save.attune_refresh_level() + 1)
|
||
return save.write_to_disk()
|