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

404 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: 钥石能否在营地换成永久强化并在下一局开局生效——冒险收束入账(大奖励 + 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()
# ---- 秘宝解锁服务(迭代 22 T2 换代——解锁混合制,契约 6;旧工坊渠道面随批删除)----
# 〔属性锻造柜台退役——迭代 21 T4/T5:buy_forge/forge_flat/forge_level/forge_price 与 meta_forge
# 表族随攻防所有权切分删除。〕〔道具工坊解锁渠道面退役——迭代 22 T2:旧「总通关次数达阈值」
# 行族与 _workshop_progress/workshop_status/refresh_workshop_unlocks 删除,解锁条件单一事实源=
# treasure_items.tres 行内 unlock_keystone_cost/unlock_requires 谓词(C1 逐件分配锁定表)。〕
# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager)
func is_item_unlocked(item_id: String) -> bool:
return save.item_unlocked(item_id)
# 解锁条件计数读数单点(谓词 key → 生涯计数——封闭枚举=TreasureItemTable.UNLOCK_COUNTER_KEYS;
# 词表加键 = 此处增一 match 分支,阈值比较与解锁写档零改动)
func _treasure_unlock_progress(key: String) -> int:
match key:
"adventure_cleared":
return save.adventure_cleared()
"boss_kill_count":
return save.boss_kill_count()
"vision_used":
return save.vision_used()
_:
return 0 # 未知计数键恒 0 = 永不解锁(fail-safe 非致命——schema 词表校验兜底,不静默误解锁)
# 钥石购买解锁(营地秘宝匣唯一购买写口):仅购买件(行带 unlock_keystone_cost)可购;
# 已解锁/余额不足/条件件误购 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币)。
func buy_treasure_unlock(item_id: String) -> bool:
var arow: Dictionary = db.treasure_item_row(item_id)
var price := int(arow.get("unlock_keystone_cost", 0))
if arow.is_empty() or price < 1 or (arow.get("unlock_requires", []) as Array).size() > 0:
return false # 非购买件(条件件/无价行)拒绝
if save.item_unlocked(item_id):
return false # 已解锁重复购买拒绝
if not save.spend_keystone(price):
return false # 余额不足(零副作用)
save.unlock_item(item_id)
return save.write_to_disk()
# 条件解锁刷新(达标未解锁 → unlock_item;返回新解锁 item_id 列——冒险收束后与营地打开时各调一次)。
# 全部谓词达标才解锁(多谓词=AND——天眼双条件);幂等:已解锁跳过,重复刷新零重复入账。
func refresh_treasure_unlocks() -> Array:
var new_unlocks: Array[String] = []
for row in db.treasure_attunement_rows():
var iid := String(row.get("item_id", ""))
if save.item_unlocked(iid):
continue
var ureqs: Array = row.get("unlock_requires", []) as Array
if ureqs.is_empty():
continue # 购买件不入条件刷新面
var met := true
for req_v in ureqs:
var req: Dictionary = req_v as Dictionary
if _treasure_unlock_progress(String(req.get("key", ""))) < int(req.get("threshold", 999)):
met = false
break
if met:
save.unlock_item(iid)
new_unlocks.append(iid)
if not new_unlocks.is_empty():
save.write_to_disk()
return new_unlocks
# 秘宝匣柜台只读投影(呈现层数据源——12 件逐行解锁态/价格/条件进度):
# {item_id, display_name, desc, tier, unlocked, mode(keystone|condition|owned), price, reqs:[{key,threshold,progress}]}
func treasure_shop_status() -> Array:
var out: Array[Dictionary] = []
for row in db.treasure_attunement_rows():
var iid := String(row.get("item_id", ""))
var unlocked := save.item_unlocked(iid)
var ureqs: Array = row.get("unlock_requires", []) as Array
var req_view: Array = []
for req_v in ureqs:
var req: Dictionary = req_v as Dictionary
req_view.append({
"key": String(req.get("key", "")),
"threshold": int(req.get("threshold", 0)),
"progress": _treasure_unlock_progress(String(req.get("key", ""))),
})
out.append({
"item_id": iid,
"display_name": String(row.get("display_name", "")),
"desc": String(row.get("desc", "")),
"tier": String(row.get("tier", "")),
"unlocked": unlocked,
"mode": "owned" if unlocked else ("condition" if ureqs.size() > 0 else "keystone"),
"price": int(row.get("unlock_keystone_cost", 0)),
"reqs": req_view,
})
return out
# ---- 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)
save.add_vision_used(int(summary.get("vision_used_delta", 0))) # 迭代 22 T3——真视使用计数收束聚合入档(信标网络行为条件)
var new_unlocks := refresh_treasure_unlocks() # 迭代 22 T2——条件解锁刷新换代(旧工坊面退役)
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 甲)----
# 装配切换(营地装配覆盖层唯一写口——冒险开始前配置;冒险中零写口=装配不可变契约 1,本口只服务营地):
# 已装 → 卸下恒可(卸下后其余件前移位序——效果面只读装配集不受格位影响);未装 → 非同调行拒绝 +
# 满位禁选 + 档位格校验(迭代 22 T2 契约 2:装入件 append 至末位 k=已装数+1,件档 rank ≤ k——
# 格 1=普通格/格 2=优秀格/格 3=稀有格递增向下兼容;槽位来源=树同调位节点点亮数,树接口接缝现役)。
# 装配关系写存档持久。
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 not save.item_unlocked(item_id):
return false # 未解锁禁装配(解锁混合制契约 6——装配以解锁为前置)
if equipped.size() >= Attunement.slots(db, save):
return false # 满位禁选
var rank: int = int(db.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99))
if rank > equipped.size() + 1:
return false # 件档高于将占格档(第 k 格档=k——普通/优秀/稀有递增)
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", "")),
"desc": String(row.get("desc", "")),
"tier": String(row.get("tier", "")),
"form": String(row.get("form", "")),
"equipped": equipped.has(iid),
"unlocked": save.item_unlocked(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()