# 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() # ---- 属性锻造(钥石直购、可重复升级——几何递增定价)---- func forge_level(attr: String) -> int: return save.forge_level(attr) # 每级强化 flat 值 × 当前级(注入面的算料——投值语言锁定 flat 整数) func forge_flat(attr: String) -> int: return forge_level(attr) * int(db.meta_forge_row(attr).get("flat_value", 0)) # 下一级价格:tier = 当前级,迭代 floor(p × ratio/100) 恰 tier 次(tier 0 = base_price) func forge_price(attr: String) -> int: var row: Dictionary = db.meta_forge_row(attr) if row.is_empty(): return 0 var p := int(row.get("base_price", 0)) var ratio := int(row.get("price_ratio_pct", 100)) for _i in forge_level(attr): p = int(floor(float(p) * float(ratio) / 100.0)) return p # 购买:余额不足 / attr 出域 false 零副作用;成功 = 消费 + 升级 + 写盘 func buy_forge(attr: String) -> bool: if db.meta_forge_row(attr).is_empty(): return false if not save.spend_keystone(forge_price(attr)): return false if not save.set_forge_level(attr, forge_level(attr) + 1): return false return save.write_to_disk() # ---- 道具工坊(首版全走通关次数渠道——钥石直购价随定价批后补,最终双渠道并存)---- # 解锁判定量单点(可替换轴——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, } # ---- 跨局注入(冒险开始参数面——字段清单随柜台扩展,现 = 属性锻造三档+树四线之三条)---- # 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。 # 迭代 12 T8:树四线 flat 并入(脊柱 stat+锻造脉+彩蛋——同一覆盖值通道;hit 线消费面= # 命中折扣档归数值批,本批不入 override 三字段通道——披露)。 func hero_override() -> Dictionary: return { "atk": forge_flat("atk") + tree_stat_flat("atk"), "max_hp": forge_flat("max_hp") + tree_stat_flat("max_hp"), "armor_class": forge_flat("armor_class") + 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 # 节点可购校验(row-gating + 显式前置 + 币 + cap;free 节点=迁移/彩蛋不经购买) func tree_can_buy(node_id: String) -> bool: var row: Dictionary = db.tree_node(node_id) if row.is_empty(): return false var lvl := save.tree_level(node_id) if lvl >= int(row.get("cap", 1)): return false var row_num := int(row.get("row", 0)) if row_num > 0 and tree_spent_total() < db.meta_tree.row_gate(row_num): return false for req in (row.get("requires", []) as Array): if save.tree_level(String(req)) < 1: return false var fg := String(row.get("fork_group", "")) if fg != "" and save.fork_side(fg) != "": return false # 同组已激活一侧——互斥(切另侧走 tree_toggle_fork 免费切换) return true # 节点购买(营地树 UI 唯一写口):**单币=钥石先行**(双币铁律中 GP 需局外余额承载域——GP 冒险 # 收束清零=局内死钱不跨局,GP 余额域缺位,脊柱 GP 节点暂走钥石消费——表 currency 字段保留语义, # T9/数值批补 GP 余额域后切回——台账披露待追认)。free=迁移点亮/彩蛋觉醒零币。 func tree_buy(node_id: String) -> bool: 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)) var currency := String(row.get("currency", "gp")) if currency == "gp" or currency == "keystone": if not save.spend_keystone(price): return false # T8 骨架单币承载(GP 余额域缺位——披露待追认) save.set_tree_level(node_id, save.tree_level(node_id) + 1) _check_easter_egg() return save.write_to_disk() # Fork 激活/切换(营地树 UI——同组换侧零币=免费切换裁定;未激活组首次激活=消费购买面 tree_buy # 已购该侧后此处激活;同侧重复=零操作) 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 == side: return false # 同侧零操作 if cur == "": return false # 未激活组走购买面(tree_buy 激活侧节点后由激活写入——见下) save.set_fork_side(group, side) return save.write_to_disk() # Fork 侧节点购买入口(购节点即激活该侧——组内互斥由 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", "")), String(row.get("fork_side", ""))) 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 fg := String(row.get("fork_group", "")) if String(sides.get(fg, "")) != String(row.get("fork_side", "")): continue for k in (row.get("effects", {}) as Dictionary).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.tree_level(String(row.get("node_id", ""))), "price": int(row.get("price", 0)), "currency": String(row.get("currency", "gp")), "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 fg in db.meta_tree.FORK_GROUPS: var group_rows: Array = [] for row in db.tree_rows(): if String(row.get("fork_group", "")) == String(fg): group_rows.append(row) if group_rows.is_empty(): continue var sides_out: Array = [] for row in group_rows: sides_out.append({ "side": String(row.get("fork_side", "")), "node_id": String(row.get("node_id", "")), "display_name": String(row.get("display_name", "")), "owned": save.tree_level(String(row.get("node_id", ""))) > 0, }) out.append({ "group": String(fg), "display_name": String(group_rows[0].get("display_name", String(fg))).split("·")[0], "active_side": String(sides.get(String(fg), "")), "sides": sides_out, }) return out # 彩蛋觉醒检查(锻造脉三节点全满→easter_ley 免费点亮——发现率监控 T10 呈报) func _check_easter_egg() -> void: var row: Dictionary = db.tree_node("easter_ley") if row.is_empty() or save.tree_level("easter_ley") > 0: return for req in (row.get("requires", []) as Array): var req_row: Dictionary = db.tree_node(String(req)) if save.tree_level(String(req)) < int(req_row.get("cap", 1)): return save.set_tree_level("easter_ley", 1) # 三体系迁移(迭代 12 T9——v4→v5 旧域只读→树域点亮;幂等=重复迁移零变化): # forge_levels 等级→锻造脉节点树级原样映射(钥石付价不变零亏损——「宁多勿少」以零亏损满足); # item_unlocks 任一真→ks_workshop 点亮;同调槽位解锁态→socket 点亮(谓词表 Slots 求值≥i)。 func migrate_tree() -> Dictionary: var migrated := {"forge": 0, "unlock": 0, "socket": 0} for pair in [["forge_atk", "atk"], ["forge_hp", "max_hp"], ["forge_ac", "armor_class"]]: var lvl := save.forge_level(String(pair[1])) if lvl > 0 and save.tree_level(String(pair[0])) < lvl: save.set_tree_level(String(pair[0]), lvl) migrated["forge"] += 1 var any_unlock := false for iid in db.treasure_live_ids().keys(): if save.item_unlocked(String(iid)): any_unlock = true break if not any_unlock: for row in db.treasure_attunement_rows(): if save.item_unlocked(String(row.get("item_id", ""))): any_unlock = true break if any_unlock and save.tree_level("ks_workshop") < 1: save.set_tree_level("ks_workshop", 1) migrated["unlock"] += 1 var slots := Attunement.slots(db, save) for i in range(1, slots + 1): if save.tree_level("att_socket%d" % i) < 1: save.set_tree_level("att_socket%d" % i, 1) migrated["socket"] += 1 save.write_to_disk() return migrated # ---- 同调位账本(迭代 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()