Files
spellforge/docs_dev/plans/2026-07-21-meta-progression-mvp.md
T
2026-07-21 11:35:44 +08:00

596 lines
22 KiB
Markdown
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.
# 元进展 MVP(碎片 + 解锁树·法术only)实现计划
> **For agentic workers:** 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 复选框跟踪。
**Goal:** 加入 Roguelite 长期粘性闭环——每局死亡按波次得以太碎片(全局持久),主菜单花碎片解锁进阶法术,解锁后的法术进入商店池;所有可调数值数据驱动、编辑器可调。
**Architecture:**`MetaProgress` autoload 管碎片+已解锁(`user://meta.json`);解锁费用在 `spells.json``meta.unlock_cost`(法术编辑器可调);碎片奖励曲线在 `balance.json.aether_thresholds`(平衡页签可调,`SettingsManager.aether_for_wave` 查表);商店池按解锁门控;死亡时 `combat_manager` 结算碎片。
**Tech Stack:** Godot 4.6 (Mono) · GDScript · 纯 JSON 数据驱动 · 验证用 Godot MCP(`execute_game_script` 游戏内 / `execute_editor_script` 编辑器内),本项目无单元测试框架。
> **验证约定**:每任务的"测试"用 MCP 运行时断言。前置:Godot 编辑器开着、MCP 连通。改代码后 `reload_project` + `get_editor_errors`0+ `play_scene(main)` 后 `execute_game_script` 调 autoload;编辑器 @tool 改动用 `execute_editor_script` + `CACHE_MODE_IGNORE` fresh-load 往返(绕过 @tool 热重载缓存)。跑完 `stop_scene`。
---
### Task 1: MetaProgress autoload(碎片+已解锁+持久化)
**Files:**
- Create: `scripts/autoloads/meta_progress.gd`
- Modify: `project.godot`[autoload] 段注册)
- [ ] **Step 1: 新建 meta_progress.gd**
```gdscript
## MetaProgress — 全局元进展(Autoload: MetaProgress
## 以太碎片 + 已解锁法术,持久化 user://meta.json(死亡不重置)。
extends Node
const PATH: String = "user://meta.json"
var aether: int = 0
var unlocked: Array = [] # 已解锁法术 id (String)
func _ready() -> void:
load_meta()
func add_aether(n: int) -> void:
if n <= 0:
return
aether += n
save_meta()
func is_unlocked(id: String) -> bool:
return id in unlocked
## 已解锁 或 碎片不足 → false(不扣费);否则扣费+记录+存档 → true
func unlock(id: String, cost: int) -> bool:
if is_unlocked(id) or aether < cost:
return false
aether -= cost
unlocked.append(id)
save_meta()
return true
func clear() -> void:
aether = 0
unlocked = []
if FileAccess.file_exists(PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(PATH))
func save_meta() -> void:
var f := FileAccess.open(PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify({"aether": aether, "unlocked": unlocked}))
f.close()
func load_meta() -> void:
aether = 0
unlocked = []
if not FileAccess.file_exists(PATH):
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(PATH))
if not (parsed is Dictionary):
return
aether = int(parsed.get("aether", 0))
var u = parsed.get("unlocked", [])
if u is Array:
for id in u:
unlocked.append(String(id))
```
- [ ] **Step 2: 注册 autoload**
`project.godot` [autoload] 段,`EndlessRecords="*res://scripts/autoloads/endless_records.gd"` 一行之后加:
```
MetaProgress="*res://scripts/autoloads/meta_progress.gd"
```
- [ ] **Step 3: 验证(MCP**`play_scene(main)` 后:
```gdscript
MetaProgress.clear()
MetaProgress.add_aether(10)
_mcp_print("add10 -> aether=%d file=%s (expect 10/true)" % [MetaProgress.aether, str(FileAccess.file_exists("user://meta.json"))])
_mcp_print("unlock frost(5) -> %s aether=%d unlocked=%s (expect true/5/[frost])" % [str(MetaProgress.unlock("action_frost_bolt", 5)), MetaProgress.aether, str(MetaProgress.unlocked)])
_mcp_print("unlock chain(8) -> %s aether=%d (expect false/5 不足)" % [str(MetaProgress.unlock("action_chain_bolt", 8)), MetaProgress.aether])
_mcp_print("unlock frost again -> %s (expect false 已解锁)" % str(MetaProgress.unlock("action_frost_bolt", 5)))
MetaProgress.aether = 0; MetaProgress.unlocked = []
MetaProgress.load_meta()
_mcp_print("reload -> aether=%d unlocked=%s (expect 5/[frost] 往返)" % [MetaProgress.aether, str(MetaProgress.unlocked)])
MetaProgress.clear()
_mcp_print("clear -> aether=%d unlocked=%s file=%s (expect 0/[]/false)" % [MetaProgress.aether, str(MetaProgress.unlocked), str(FileAccess.file_exists("user://meta.json"))])
```
Expected: 全部符合括号。`get_editor_errors`=0。
- [ ] **Step 4: Commit**
```bash
git add scripts/autoloads/meta_progress.gd project.godot
git commit -m "feat(meta): MetaProgress autoload(碎片+已解锁+持久化 user://meta.json"
```
---
### Task 2: 碎片奖励曲线(balance.json 阈值表 + SettingsManager+ 清除数据钩子
**Files:**
- Modify: `data/balance.json`
- Modify: `scripts/autoloads/settings_manager.gd`
- [ ] **Step 1: balance.json 加阈值表**
`data/balance.json` 整体替换为:
```json
{
"difficulty_mults": {
"enemy_hp": [0.7, 1.0, 1.0],
"player_dmg": [0.7, 1.0, 1.0],
"wave_count": [1.0, 1.0, 1.2],
"boss_hp": [1.0, 1.0, 1.3]
},
"boss_hp": {
"4": 450.0,
"5": 1800.0
},
"aether_thresholds": [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]
}
```
- [ ] **Step 2: SettingsManager 加载 + 查表**
`settings_manager.gd` 第 35 行 `var _BOSS_HP: Dictionary = {}` 之后加:
```gdscript
var _AETHER_THRESHOLDS: Array = [] # [[到达波次, 碎片], ...] 升序
```
`_load_balance()``if data.has("boss_hp") ...: _BOSS_HP = data["boss_hp"]` 之后加:
```gdscript
if data.has("aether_thresholds") and data["aether_thresholds"] is Array:
_AETHER_THRESHOLDS = data["aether_thresholds"]
```
`get_boss_base_hp()` 函数之后加方法:
```gdscript
## 到达波次 → 以太碎片(阶梯查表:取"波次阈值 ≤ wave"的最高阶梯值;升序表)
func aether_for_wave(wave: int) -> int:
var table: Array = _AETHER_THRESHOLDS
if table.is_empty():
table = [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]
var result: int = 0
for entry in table:
if entry is Array and entry.size() >= 2 and int(entry[0]) <= wave:
result = int(entry[1])
return result
```
- [ ] **Step 3: 清除数据钩子**
`clear_all_local_data()``_apply_audio()` 调用之前(第 114 行前)加:
```gdscript
MetaProgress.clear()
```
- [ ] **Step 4: 验证(MCP**
```gdscript
for w in [1, 5, 10, 12, 15, 20, 25]:
_mcp_print("wave %d -> aether %d" % [w, SettingsManager.aether_for_wave(w)])
```
Expected: 1→0, 5→3, 10→8, 12→8, 15→14, 20→20, 25→20。`get_editor_errors`=0。
- [ ] **Step 5: Commit**
```bash
git add data/balance.json scripts/autoloads/settings_manager.gd
git commit -m "feat(meta): 碎片奖励阈值表(balance.json)+aether_for_wave+清除数据钩子"
```
---
### Task 3: spells.json 加 unlock_cost6 张锁定法术)
**Files:**
- Modify: `data/spells.json`
- [ ] **Step 1: 给 6 张法术 meta 加 unlock_cost**
在下列法术的 `meta` 对象里加 `"unlock_cost": N`(放 `shop_cost` 之前):
| id | unlock_cost |
| :--- | :--- |
| action_energy_orb | 4 |
| action_frost_bolt | 5 |
| action_poison_pool | 6 |
| logic_loop | 6 |
| action_chain_bolt | 8 |
| action_summon_turret | 10 |
示例(`action_chain_bolt` 一行):
```json
"action_chain_bolt": { "type": 0, "display_name": "Chain Bolt", "description": "连锁闪电(雷元素),与水共鸣成等离子风暴。", "element_tags": ["tag:lightning"], "meta": { "base_damage": 3.0, "speed": 420.0, "lifetime": 3.0, "radius": 6.0, "damage_type": 0, "mana_cost": 60, "unlock_cost": 8, "shop_cost": 20 } },
```
> 注意:`mana_cost` 已在(来自 Mana MVP),别删;`unlock_cost` 加在其后、`shop_cost` 前。其余 13 张不加(= 开局解锁)。
- [ ] **Step 2: 验证(MCP**
```gdscript
for id in ["action_energy_orb","action_frost_bolt","action_poison_pool","logic_loop","action_chain_bolt","action_summon_turret","action_spark_bolt"]:
var s = SpellRegistry.get_spell(id)
if s: _mcp_print("%s unlock_cost=%s" % [id, str(s.meta.get("unlock_cost", 0))])
```
Expected: energy_orb=4, frost=5, poison_pool=6, loop=6, chain=8, turret=10, spark_bolt=0(未加)。`get_editor_errors`=0JSON 合法)。
- [ ] **Step 3: Commit**
```bash
git add data/spells.json
git commit -m "feat(meta): spells.json 6 张进阶法术加 unlock_cost(解锁树)"
```
---
### Task 4: 商店池按解锁门控
**Files:**
- Modify: `scripts/autoloads/shop_manager.gd:33-46``_refresh_slots`,抽出可测的 `_available_pool`
- [ ] **Step 1: 抽出 `_available_pool` 并加门控**
`_refresh_slots()` 开头的 pool 构造(第 34-37 行)替换为调用新方法,并在其上方新增方法。即将:
```gdscript
func _refresh_slots() -> void:
# 纯数据驱动池:SpellRegistrydata/spells.json)中可购买(shop_cost>0)的法术
var pool: Array = SpellRegistry.get_all_ids().filter(func(id):
var s: SpellNode = SpellRegistry.get_spell(id)
return s != null and int(s.meta.get("shop_cost", 0)) > 0)
```
改为:
```gdscript
## 商店可抽池:shop_cost>0 且(未门控 unlock_cost==0 或 已解锁)
func _available_pool() -> Array:
return SpellRegistry.get_all_ids().filter(func(id):
var s: SpellNode = SpellRegistry.get_spell(id)
if s == null or int(s.meta.get("shop_cost", 0)) <= 0:
return false
var uc: int = int(s.meta.get("unlock_cost", 0))
return uc == 0 or MetaProgress.is_unlocked(id))
func _refresh_slots() -> void:
var pool: Array = _available_pool()
```
(其余 `_refresh_slots` 逻辑不变。)
- [ ] **Step 2: 验证(MCP**
```gdscript
MetaProgress.clear() # unlocked 空
var pool1 = ShopManager._available_pool()
_mcp_print("locked chain in pool=%s (expect false)" % str(pool1.has("action_chain_bolt")))
_mcp_print("basic spark in pool=%s (expect true)" % str(pool1.has("action_spark_bolt")))
MetaProgress.unlock("action_chain_bolt", 0) # 强制解锁(cost0 便于测试)
var pool2 = ShopManager._available_pool()
_mcp_print("after unlock, chain in pool=%s (expect true)" % str(pool2.has("action_chain_bolt")))
MetaProgress.clear()
```
Expected: false / true / true。`get_editor_errors`=0。
- [ ] **Step 3: Commit**
```bash
git add scripts/autoloads/shop_manager.gd
git commit -m "feat(meta): 商店池按 unlock_cost/已解锁门控(_available_pool"
```
---
### Task 5: 死亡结算发碎片(combat_manager
**Files:**
- Modify: `scripts/domain/combat/combat_manager.gd:230-246``_on_player_died`
- [ ] **Step 1: 加碎片结算**
`_on_player_died()``EndlessRecords.add_record(wave_reached, elapsed, kills)`(第 237 行)之后加:
```gdscript
MetaProgress.add_aether(SettingsManager.aether_for_wave(wave_reached))
```
- [ ] **Step 2: 验证(MCP,需战斗场景)**
`play_scene(main)``execute_game_script`: `SceneManager.start_new_game()`。下一次调用:
```gdscript
var cs = get_tree().current_scene
MetaProgress.clear()
WaveManager.current_wave = 10
cs._cm._run_start_time = Time.get_ticks_msec() / 1000.0
cs._cm._on_player_died({})
_mcp_print("died at W10 -> aether=%d (expect 8)" % MetaProgress.aether)
MetaProgress.clear()
```
Expected: `aether=8``get_editor_errors`=0。`stop_scene`
- [ ] **Step 3: Commit**
```bash
git add scripts/domain/combat/combat_manager.gd
git commit -m "feat(meta): 死亡按到达波次结算以太碎片"
```
---
### Task 6: i18n 键(META_* / MENU_UNLOCK4 语言)
**Files:**
- Modify: `translations/zh_CN.po`, `zh_TW.po`, `en.po`, `ja.po`
- [ ] **Step 1: 各 .po 末尾追加键**
`zh_CN.po` 追加:
```
msgid "MENU_UNLOCK"
msgstr "🔓 解锁"
msgid "META_TITLE"
msgstr "元进展 · 解锁"
msgid "META_AETHER"
msgstr "以太碎片: %d"
msgid "META_UNLOCK_BTN"
msgstr "解锁 (%d)"
msgid "META_UNLOCKED"
msgstr "已解锁"
msgid "META_CLOSE"
msgstr "关闭"
```
`zh_TW.po` 追加(同键,繁体):`🔓 解鎖` / `元進展 · 解鎖` / `以太碎片: %d` / `解鎖 (%d)` / `已解鎖` / `關閉`
`en.po` 追加:`🔓 Unlocks` / `Meta · Unlocks` / `Aether: %d` / `Unlock (%d)` / `Unlocked` / `Close`
`ja.po` 追加:`🔓 アンロック` / `メタ進行 · アンロック` / `エーテル: %d` / `アンロック (%d)` / `解放済み` / `閉じる`
- [ ] **Step 2: 验证(MCP**
```gdscript
for k in ["MENU_UNLOCK","META_TITLE","META_AETHER","META_UNLOCK_BTN","META_UNLOCKED","META_CLOSE"]:
_mcp_print("%s -> %s" % [k, TranslationServer.translate(k)])
```
Expected: 每个键返回中文译文(非键本身,zh_CN 下)。`get_editor_errors`=0。
- [ ] **Step 3: Commit**
```bash
git add translations/zh_CN.po translations/zh_TW.po translations/en.po translations/ja.po
git commit -m "feat(meta): META_*/MENU_UNLOCK 四语 i18n 键"
```
---
### Task 7: 主菜单「🔓 解锁」按钮 + 解锁面板
**Files:**
- Modify: `scenes/ui/main_menu.gd`
- [ ] **Step 1: 成员 + _ready + 菜单项 + ESC + 语言重建**
a)在顶部成员区(`var _ftue_layer: CanvasLayer = null` 之后)加:
```gdscript
var _unlock_layer: CanvasLayer = null
var _unlock_root: Control = null
```
b)在 `_ready()``_build_ftue_panel()` 之后加 `_build_unlock_panel()`
c)在 `_build_menu()``items` 数组里,`["MENU_LEADERBOARD", _on_leaderboard],` 之后插入:
```gdscript
["MENU_UNLOCK", _on_unlock],
```
d)在 `_input()``elif _leaderboard_layer and _leaderboard_layer.visible:` 分支之后加:
```gdscript
elif _unlock_layer and _unlock_layer.visible:
_unlock_layer.visible = false
```
e)在 `_on_lang()``_leaderboard_layer = null` 之后加 `_unlock_layer = null`,并在末尾 `_build_leaderboard_panel()` 之后加 `_build_unlock_panel()`
- [ ] **Step 2: 回调 + 面板构建/刷新**
在文件末尾追加:
```gdscript
# ── 解锁面板 ─────────────────────────────────────────────────
func _on_unlock() -> void:
_unlock_layer.visible = true
_rebuild_unlock()
func _build_unlock_panel() -> void:
_unlock_layer = CanvasLayer.new()
_unlock_layer.layer = 10
_unlock_layer.visible = false
add_child(_unlock_layer)
var bg := ColorRect.new()
bg.color = Color(0.0, 0.0, 0.0, 0.72)
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_unlock_layer.add_child(bg)
_unlock_root = Control.new()
_unlock_layer.add_child(_unlock_root)
func _rebuild_unlock() -> void:
for c in _unlock_root.get_children():
c.queue_free()
var panel := ColorRect.new()
panel.color = Color(0.06, 0.06, 0.14, 0.98)
panel.size = Vector2(560, 470)
panel.position = Vector2(300, 90)
_unlock_root.add_child(panel)
var title := Label.new()
title.text = tr("META_TITLE")
title.position = Vector2(324, 106)
title.add_theme_font_size_override("font_size", 28)
title.modulate = Color.GOLD
_unlock_root.add_child(title)
var aether_lbl := Label.new()
aether_lbl.text = tr("META_AETHER") % MetaProgress.aether
aether_lbl.position = Vector2(324, 148)
aether_lbl.add_theme_font_size_override("font_size", 18)
aether_lbl.modulate = Color(0.6, 0.85, 1.0)
_unlock_root.add_child(aether_lbl)
var y: float = 188.0
for id in SpellRegistry.get_all_ids():
var s: SpellNode = SpellRegistry.get_spell(id)
if s == null:
continue
var cost: int = int(s.meta.get("unlock_cost", 0))
if cost <= 0:
continue
var name_lbl := Label.new()
name_lbl.text = s.display_name
name_lbl.position = Vector2(324, y + 4)
name_lbl.add_theme_font_size_override("font_size", 16)
_unlock_root.add_child(name_lbl)
var btn := Button.new()
btn.position = Vector2(560, y)
btn.size = Vector2(230, 32)
if MetaProgress.is_unlocked(id):
btn.text = tr("META_UNLOCKED")
btn.disabled = true
else:
btn.text = tr("META_UNLOCK_BTN") % cost
btn.disabled = MetaProgress.aether < cost
var sid := id
var scost := cost
btn.connect("pressed", func():
if MetaProgress.unlock(sid, scost):
AudioManager.play("buy")
_rebuild_unlock())
_unlock_root.add_child(btn)
y += 40.0
var close := Button.new()
close.text = tr("META_CLOSE")
close.position = Vector2(700, 508)
close.size = Vector2(120, 36)
close.connect("pressed", func(): _unlock_layer.visible = false)
_unlock_root.add_child(close)
```
- [ ] **Step 3: 验证(MCP,编辑器内 fresh-load 往返 + 游戏内截图)**
游戏内(`play_scene(main)`,当前场景即 MainMenu):
```gdscript
var mm = get_tree().current_scene
MetaProgress.clear(); MetaProgress.add_aether(20)
mm._on_unlock()
_mcp_print("unlock panel visible=%s aether label ok=%s" % [str(mm._unlock_layer.visible), str(mm._unlock_root.get_child_count() > 3)])
# 找到第一个可点解锁按钮并点击(frost_bolt cost5aether20 够)
MetaProgress.unlock("action_frost_bolt", 5)
_mcp_print("after unlock frost: aether=%d is_unlocked=%s (expect 15/true)" % [MetaProgress.aether, str(MetaProgress.is_unlocked("action_frost_bolt"))])
MetaProgress.clear()
```
然后 `get_game_screenshot` 确认「元进展·解锁」面板渲染出碎片数 + 6 行锁定法术 + 解锁按钮。Expected: panel visible=true。`get_editor_errors`=0。`stop_scene`
- [ ] **Step 4: Commit**
```bash
git add scenes/ui/main_menu.gd
git commit -m "feat(meta): 主菜单解锁面板(花碎片解锁法术)"
```
---
### Task 8: 平衡页签支持 aether_thresholds(编辑器可调 + 保存不丢字段)
**Files:**
- Modify: `addons/game_designer/balance_tab.gd`
- [ ] **Step 1: 加 JSON 文本框(读)+ 保存时写回**
a)在成员区(`var _boss: SpinBox` 之后)加:
```gdscript
var _aether: TextEdit
```
b)在 `_ready()``add_child(HSeparator.new())`(Boss 血量之后、保存按钮之前)与保存按钮之间加:
```gdscript
var al := Label.new(); al.text = "碎片奖励阈值 aether_thresholdsJSON [[波次,碎片],...]"; add_child(al)
_aether = TextEdit.new(); _aether.custom_minimum_size = Vector2(0, 50)
_aether.text = JSON.stringify(_data.get("aether_thresholds", [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]))
add_child(_aether)
```
c)在 `_save()` 里,构造 `out` 时加入该字段:把
```gdscript
var out := {
"difficulty_mults": mults,
"boss_hp": {"4": _miniboss.value, "5": _boss.value},
}
```
改为:
```gdscript
var thresholds = JSON.parse_string(_aether.text) if _aether.text.strip_edges() != "" else []
if not (thresholds is Array):
thresholds = []
var out := {
"difficulty_mults": mults,
"boss_hp": {"4": _miniboss.value, "5": _boss.value},
"aether_thresholds": thresholds,
}
```
- [ ] **Step 2: 验证(编辑器 MCPfresh-load 往返)**
```gdscript
var Tab = ResourceLoader.load("res://addons/game_designer/balance_tab.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
var t = Tab.new(); EditorInterface.get_base_control().add_child(t)
_mcp_print("aether box has data=%s" % str(t._aether.text.contains("[")))
# 模拟保存构造的 out(不写盘):直接读 _aether 解析
var parsed = JSON.parse_string(t._aether.text)
_mcp_print("thresholds parse ok=%s first=%s (expect true/[1,0])" % [str(parsed is Array), str(parsed[0]) if parsed is Array else "N/A"])
t.free()
```
Expected: `has data=true``parse ok=true first=[1, 0]``get_editor_errors`=0(清 stale 后)。
- [ ] **Step 3: Commit**
```bash
git add addons/game_designer/balance_tab.gd
git commit -m "feat(meta): 平衡页签可编辑 aether_thresholds(保存不丢字段)"
```
---
### Task 9: 更新 game_design §7 现状 callout
**Files:**
- Modify: `docs/design/game_design.md`(§7 callout
- [ ] **Step 1: 替换 §7 callout**
`docs/design/game_design.md` §7 的「本章整体未实现」callout 替换为:
```markdown
> **⚠️ 实现现状 (2026-07-21 → MVP 部分实现)**:**碎片 + 法术解锁树已落地**——`MetaProgress` autoload`user://meta.json` 碎片+已解锁)、死亡按波次发碎片(`balance.json.aether_thresholds` 阈值表,平衡页签可调)、主菜单「🔓解锁」面板花碎片解锁进阶法术、商店池按 `spells.json` 的 `meta.unlock_cost` 门控。**暂缺**:核心解锁(依赖货架B)、挑战模式解锁、图鉴 Codex、成就/炼金日志、通关首胜奖励。详见 `docs_dev/specs/2026-07-21-meta-progression-mvp-design.md`。
```
- [ ] **Step 2: 验证**`git grep -n "本章整体未实现" docs/design/game_design.md` 应无结果。
- [ ] **Step 3: Commit**
```bash
git add docs/design/game_design.md
git commit -m "docs(meta): game_design §7 现状更新为碎片+解锁树已实现(MVP)"
```
---
### Task 10: 全量回归验收
**Files:** 无(纯验证)
- [ ] **Step 1: 重载 + 0 错误**`reload_project``clear_output``get_editor_errors`=0。
- [ ] **Step 2: 端到端闭环**`play_scene(main)`):
```gdscript
MetaProgress.clear()
# 模拟一局到 W10 死亡发碎片
var cm_sm = SettingsManager.aether_for_wave(10)
MetaProgress.add_aether(cm_sm)
_mcp_print("W10 死亡得碎片=%d,总 aether=%d (expect 8/8)" % [cm_sm, MetaProgress.aether])
# 商店池此刻不含 chain_bolt
_mcp_print("池含 chain(锁定)=%s (expect false)" % str(ShopManager._available_pool().has("action_chain_bolt")))
# 花碎片解锁 chain(8)
_mcp_print("解锁 chain -> %saether=%d (expect true/0)" % [str(MetaProgress.unlock("action_chain_bolt", 8)), MetaProgress.aether])
# 现在池含 chain
_mcp_print("池含 chain(已解锁)=%s (expect true)" % str(ShopManager._available_pool().has("action_chain_bolt")))
MetaProgress.clear()
```
Expected: 8/8 → false → true/0 → true。
- [ ] **Step 3: DoD 核对**spec §8)逐条确认。`stop_scene`
- [ ] **Step 4: Commit**
```bash
git commit --allow-empty -m "chore(meta): 元进展 MVP 验收通过(Godot 4.6 运行时实测闭环全绿)"
```
---
## 自检结论
- **Spec 覆盖**:§2.1→T1;§2.4/碎片曲线→T2/T5;§2.2 unlock_cost→T3;§2.3 门控→T4;解锁面板→T7;i18n→T6;编辑器可调(balance_tab)→T8;清除数据→T2docs→T9;测试→各任务+T10。无遗漏。
- **无占位**:每步含精确文件/行号/完整代码/期望输出。
- **类型一致**`MetaProgress.aether/unlocked` + `add_aether/is_unlocked/unlock/clear/save_meta/load_meta``SettingsManager.aether_for_wave``ShopManager._available_pool``meta.unlock_cost``balance.json.aether_thresholds` 全程一致。