docs(spec): E3-② 货架 C(属性购买)设计——PriceFormula 模块 + shop 段数据驱动

按用户指定的架构方向,定价由专门模块 PriceFormula 负责(与 AttributeFormula
同构:纯静态零依赖,故可脱离游戏进程单元断言)。模块不认识「属性/武器/装备」,
只认识 {price_base, price_growth, curve}——划分点在数据里,故货架 B 与出售退款
可直接复用。

可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,自动覆盖 7 个
「已定义但被阻塞」的属性,将来解除前置只需加一段 JSON,零代码。

附完整角色属性盘点(6 类):框架内 4 个可售、权威表已定义未进框架 7 个、
PlayerStats 里权威表漏记的事实属性 2 个(mana_regen/mana_leech,权威给货架 C
举的「回蓝速度」正是前者)、资源与进度、法杖侧、全局配置。

查明 numerical_design §1.2 消耗清单缺属性购买一项,故定价属设计而非查表,
定稿后补录列为本设计的组成部分。

自审订正一处方向性错误:初稿称 cast_delay_mod 的饱和点高于 soft、需逐杖动态
判定——反了。inverse 属性数值更低=更快,三个饱和点全部低于 soft(0.1),正常
购买够不到,UI 只需 soft 一道闸。差点规定一个永远触发不了的功能。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:35:18 +08:00
co-authored by Claude Opus 5
parent d7420a284f
commit 06d66dbd1a
@@ -0,0 +1,266 @@
# 货架 C(属性购买)设计
> **日期**2026-07-31
> **Epic**E3 经济与 build · 子计划 ②(缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`
> **优先级**P0 · 工作量 S~M
> **目标**:商店可购买属性提升,消耗金币,经 E3-① 的加成层叠加到 `PlayerStats`;新增 `PriceFormula` 定价模块。
---
## 0. 范围与前置
**依赖**E3-①「属性词条系统」已完成(`master` tip `9361bf8`),提供 `AttributeFormula``PlayerStats.add_modifier` / `remove_modifiers_from(source)``attributes.json``soft` 字段(E3-① 特意保留、当时无消费方,本期接上)。
**本期做**:可售属性的数据段、`PriceFormula` 模块、`ShopManager` 的货架 C 状态与购买流程、局内存档、商店 UI、设计器扩展、权威文档补录。
**本期不做**(各有独立理由,见 §1.2):把 B 类「已定义但被阻塞」的 7 个属性变为可售;出售退款(子计划 ④);货架 B 核心抽取(子计划 ③);E7-⑥ 计算模块化重构(已单独立项)。
## 1. 背景与现状(代码复核 2026-07-31
### 1.1 商店现状
`shop_manager.gd` 仅有货架 A`current_slots`3 个法术槽)、`buy_spell``reroll`(费用 `20 + n×10`,P6-N23,波次结算后重置)。grep 确认无 shelf/sell/core/attr 任何字段。商店 UI 全程序化,在 `combat_s2.gd:281 _setup_shop_ui()`(3 个槽位按钮 + 刷新 + 法杖 + 背包)。
### 1.2 角色属性全貌盘点
货架 C 的可售集合会持续扩展,故先盘清全貌。**只有 A 类今天可卖。**
**A. 已在属性框架内 —— 4 个(本期可售)**
| id | base | soft | hard | combine | 消费点 |
| :-- | --: | --: | --: | :-- | :-- |
| `cpu_limit` | 0 | 20 | 50 | `add_int` | `spell_evaluator``MAX_OPS = 值 × 40` |
| `move_speed` | 200 | 600 | 800 | `hybrid` | `player_manager` 每物理帧 |
| `cast_delay_mod` | 1.0 | 0.1 | 0.05 | `inverse` | `_handle_auto_cast` 开火时 |
| `hp_max` | 100 | 2000 | 无 | `hybrid` | 治疗钳制 / 血量百分比 / 卖血上限 |
**B. 权威表已定义、未进框架 —— 7 个(各有明确前置,本期不做)**
| id | 权威 base/soft/hard | 卡在哪 |
| :-- | :-- | :-- |
| `mana_max` | 100 / 1000 / — | 现状由核心 `base_mana_max` 直接覆盖;权威 §1.1 说「与法杖取 Min」,该语义未决(E3-① 记为遗留) |
| `recharge_speed_mod` | 1.0 / 5.0 / — | **充能系统不存在**(全项目 grep `recharge` 零命中) |
| `luck` | 0 / 100 / — | 权威说它影响「高阶法术掉率 + 暴击率」,**两条都没接**:暴击链是死的(`bullet_manager.gd:81``is_crit` 硬编码 `false`),掉率无 luck 输入 |
| `attunement_fire/ice/lightning/poison` | 0 / 50 / 100 | 每点 +3% 元素伤 + 2% 状态触发率,需接入伤害管线与状态系统 |
**C. `PlayerStats` 实际字段但不在权威表内 —— 4 个**
| 字段 | 性质 | 写入方 |
| :-- | :-- | :-- |
| `mana` | 运行时状态,非属性 | 施法消耗 / 每帧回复 |
| `mana_regen` | **事实上的属性**,被核心覆盖 | `set_mana_pool``core.base_mana_regen` |
| `mana_leech` | **事实上的属性**,多来源重算 | `_rebuild_wand` ← 核心 + 卡组 `meta.mana_leech` |
| `mana_heat` | 运行时状态(递增蓝耗热值) | 每次施法 +,每帧衰减 |
> ⚠️ `mana_regen` / `mana_leech` 是**权威表漏了的真实属性**(有基准值、被多来源修正、影响玩法)。而权威给货架 C 举的例子「升级回蓝速度」指的正是 `mana_regen` —— 它是最可能先补进框架的候选。
**D. 资源与进度(不是属性,不上架)**`hp`(当前值)、`gold``xp``level``xp_to_next``_invuln_until_msec`
**E. 法杖侧属性(属于装备,是货架 B 的范畴)**`CoreDefinition``slot_count` / `cpu_limit` / `cast_interval` / 拓扑 / `feature_tags` / `base_mana_max`+`regen`+`leech`
**F. 全局配置(影响角色但非角色属性,设计师调不该按局买)**`balance.json``player_iframe_sec``difficulty_mults.player_dmg``mana_heat{...}``infinite_spells{...}`
### 1.3 权威文档说了什么
`docs/design/game_design.md §5`:货架 C = 「基础属性提升(生命值、暴击率)」,位于**局内循环**的「购物」环节,与货架 A/B 并列。经济策略原文:
> 是买一个强力的"黑洞"法术?还是**把钱**用来升级"回蓝速度"以支撑现有的构建?
**结论:局内成长、消耗金币、与法术购买抢同一笔钱。** 路线图写的「金币/以太」是含糊 —— 以太(`MetaProgress.aether`)存在 `user://meta.json`、跨局持久、用于**永久解锁法术**,与货架 C 语义不符。
这也意味着 E3-① 的 `reset_for_run()` 清空 `_modifiers` 是**正确的**,无需改动。
> 权威举的三个例子中,「生命值」可用,「暴击率」被暴击链阻塞,「回蓝速度」(`mana_regen`) 不在框架内。**举例是示意不是规格** —— 本期按「框架内现成可卖的」取集合。
### 1.4 定价无权威(本设计需补录)
`numerical_design.md` §1.2 经济系统的**消耗清单只有三项**:购买法术 (50-500G)、购买法杖 (200-2000G)、刷新商店 (20G)。**属性购买缺席**;§1.1 也只给 base/soft/hard,无成长曲线。
故本期定价属**设计而非查表**,且定稿后必须补回 §1.2(见 §2.7)。
> 与归航(E1-③)那次的对照:那次是**有权威而没查**`numerical_design` 的 Mana 列与每个已实现法术吻合,证明它是活的),属真错误。本次是**查了、确认没有**。「以权威为准」的前提是先确认权威存在。
## 2. 设计
### 2.1 `PriceFormula` —— 定价的唯一实现处
**架构原则(用户 2026-07-31 指定)**:凡涉及计算的都由专门功能模块负责,便于测试与配置。`AttributeFormula` 是已验证的模板。
`scripts/domain/price_formula.gd`
```gdscript
## PriceFormula — 价格计算的唯一实现处
## 纯静态函数:无状态、不依赖 ShopManager / PlayerStats / 场景树,故可脱离游戏进程单元测试
class_name PriceFormula
extends RefCounted
enum Curve { GEOMETRIC, LINEAR, FLAT }
## 唯一入口。spec 来自数据文件;本模块**不认识**「属性/法杖/装备」,只认识 {price_base, price_growth, curve}
## purchased —— 已购次数(0 = 首次购买)
static func compute(spec: Dictionary, purchased: int) -> int
## curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC
static func curve_from_string(s: String) -> Curve
```
| curve | 公式 |
| :-- | :-- |
| `geometric`(默认) | `price_base × price_growth^n` |
| `linear` | `price_base + n × price_growth` |
| `flat` | `price_base` |
返回 `int`(金币是整数),内部 `roundi`;下限 1(免费购买无意义)。
**模块对「卖什么」一无所知**,这是货架 B(核心抽取)与子计划 ④(出售退款)能直接复用它的前提。「按不同属性、武器、装备细致划分」的划分点在**数据**里,不在模块里。
### 2.2 `attributes.json` 的 `shop` 段
```json
"hp_max": {
"display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid",
"shop": { "mode": "pct", "step": 0.10, "price_base": 60, "price_growth": 1.15, "curve": "geometric" }
}
```
**`shop` 段缺失 = 不可售。** 这自动覆盖 §1.2 B 类那 7 个被阻塞的属性 —— 它们将来进框架时不写 `shop` 段即不上架,前置解决后加一段即可,**零代码**。
本期初值(设计器可调,真正的平衡交给试玩):
| 属性 | mode | step | price_base | price_growth | 到 `soft` 约需 |
| :-- | :-- | --: | --: | --: | --: |
| `hp_max` | `pct` | 0.10 | 60 | 1.15 | 31 次 |
| `move_speed` | `pct` | 0.08 | 70 | 1.15 | 14 次 |
| `cast_delay_mod` | `pct` | 0.10 | 90 | 1.20 | 22 次 |
| `cpu_limit` | **`flat`** | 1 | 120 | 1.30 | 1217 次 |
**`cpu_limit` 必须 `flat`** —— `AttributeFormula``add_int` 明确拒绝 `pct``push_error`(乘算对离散指令预算无意义)。这条硬约束正是「增量必须逐属性配置」的原因,不是偏好。
首购 60–120G 与最便宜的法术(50G)同档,形成真实竞争(每波收入约 150–250G:击杀 15G + 波次结算 100G + 10% 利息)。
**为什么 `pct` 配 `geometric`**`pct` 模式下第 n 次购买的**绝对收益**是 `base × (1+step)^(n-1) × step`,本身几何增长;价格若只线性涨,后期购买会越来越划算,最优解退化为「无脑堆一条轨」,取舍消失。取 `price_growth` 略大于 `1+step` 得到温和的边际递减 —— 每次仍值得买,但一直堆同轨会逐渐不划算,自然鼓励铺开。
> **刷新费用(P6-N23)用线性是对的**,因为刷新的价值每次都一样;属性购买的价值在复利,情况不同,不该照搬。
**价格递增按「本局该属性累计购买次数」计,不随波次重置**(与刷新费用相反)—— 它代表累计投资深度,波次重置会让「每波买一次」变成无脑最优。
### 2.3 `cast_delay_mod` 的饱和点够不到 —— `soft` 才是约束(自审订正)
E3-① 实测:`_handle_auto_cast``if` 而非 `while`,每物理帧至多施法一次,故存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic` 0.0333、`wand_fast` 0.0667、`circuit_fork` 0.0278。
**但正常购买够不到任何一个饱和点。** `cast_delay_mod` 越低越快,购买从 1.0 往下走,而 `soft`(0.1) 先把你拦住;三个饱和点(0.0333 / 0.0667 / 0.0278**全部低于 0.1**
| 法杖 | 基准间隔 | `soft`(0.1) 时的实际间隔 | 帧距 | 速率 | 饱和? |
| :-- | --: | --: | --: | --: | :-- |
| `wand_basic` | 0.5 s | 0.050 s | 3 帧 | 20 /s | 否(饱和需 ≤ 0.0333 |
| `wand_fast` | 0.25 s | 0.025 s | 2 帧 | 30 /s | 否(饱和需 ≤ 0.0667 |
| `circuit_fork` | 0.6 s | 0.060 s | 4 帧 | 15 /s | 否(饱和需 ≤ 0.0278 |
**故货架 C 的 UI 只需 `soft` 一道闸,不需要动态的逐杖饱和判定。**
> 📌 **自审订正记录**:本节初稿写「饱和点高于 `soft`,买到 `soft` 之前就已饱和,UI 必须做逐杖动态判定」—— **方向搞反了**。`inverse` 属性的「高/低」在语义上易反:数值更低 = 更快 = 更好,故「饱和点 0.0333 低于 soft 0.1」意味着**先撞 soft**,而非先饱和。差点规定了一个永远触发不了的 UI 功能。教训与本项目已记录的三次「断言宽到抓不住」同源:**`inverse` 语义下任何「高于/低于」的论断都必须代入具体数字验算一次**。
饱和点只在**将来出现能突破 `soft` 的特殊来源**(传奇核心 / 元进展)时才成为约束,届时才需要那套判定。完整实测数据见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。
### 2.4 状态与唯一写入点
`ShopManager` 新增 `_attr_purchases: Dictionary``attr_id: String``次数: int`)。
购买流程:
```
校验 shop 段存在 → 校验金币足够 → 校验未达 soft / 未饱和
→ gold -= PriceFormula.compute(spec, n)
→ _attr_purchases[id] = n + 1
→ _apply_attr_purchases()
```
`_apply_attr_purchases()` 重建加成(与 `_rebuild_wand` 处理 `"core"` 完全同构):
```gdscript
PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C)
for id in _attr_purchases:
var n: int = _attr_purchases[id]
if n <= 0: continue
var sp: Dictionary = <attributes.json[id].shop>
var merged: float = (pow(1.0 + step, n) - 1.0) if mode == "pct" else (step * n)
PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C)
```
**每属性至多一条 `_modifiers`**`value` 为合并值。选此形状而非「每次购买追加一条」的理由:
- `pct` 模式下 3 条 `+10%` 连乘为 ×1.331,**没有等价的单一 `pct` 值**(除非写 0.331);合并时直接算 `(1+step)^n - 1`,语义明确,不必要求公式模块对同源多条做特殊处理。
- 出售退款(子计划 ④)只需次数减一后重算,**不需要给 `PlayerStats` 新增按条撤销的 API**。
**唯一写入点**:所有改动 `_attr_purchases` 的路径(购买、出售退款、存档回读、`reset`)都必须经 `_apply_attr_purchases()``_attr_purchases``_modifiers` 不同步会产生「显示买了 3 次但加成只有 2 次」,且**无任何诊断** —— 收敛写入点是唯一防线。
新增常量 `ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`(理由同 `PlayerStats.MOD_SOURCE_CORE`:来源串在 remove/add 两侧必须逐字相同,打错即静默累积)。
### 2.5 持久化
**只存 `_attr_purchases`,不存 `_modifiers`** —— 后者是前者的派生物,回读时经 `_apply_attr_purchases()` 重建。
这躲开了 E3-① 在 `player_stats.gd` 留的警告:`_modifiers``Array[Dictionary]`,而 `JSON.parse_string` / `data.get(..., [])` 产出无类型 `Array`,直接 `=` 赋值是运行时类型错误,必须 `.assign()`。存派生源头则完全不涉及这个坑。
- 进**局内存档**`ProfileManager` run_a/run_b),不进 `meta.json` —— 货架 C 是局内成长(§1.3)。
- `SCHEMA_VERSION` 2 → 3;旧存档无该键 → 视为空字典(无购买),不需要迁移逻辑。
- 回读顺序:先 `_attr_purchases` 赋值,再 `_apply_attr_purchases()`,最后才是依赖 `hp_max` 的量(E3-① 记录的顺序陷阱:`_recompute_attrs``hp = minf(hp, hp_max)` 若拿未加成的 `hp_max` 去钳会静默吞血)。
- `ShopManager.reset()` 清空 `_attr_purchases``PlayerStats.reset_for_run()` 已清 `_modifiers`)。
### 2.6 UI 与设计器
**商店面板**`combat_s2.gd` 现有程序化风格,项目无 `.tscn` UI)新增「属性」区:每个可售属性一行 —— 名称 / 当前生效值 / 下次价格 / 购买按钮。禁用态需**说明原因**(金币不足 / 已达 `soft` 上限),而非只是灰掉。
**设计器「属性」页**扩展 `shop` 段五字段:`mode` 下拉(存 key 不存显示串)、`step` / `price_base` / `price_growth` SpinBox、`curve` 下拉。
⚠️ **`UI.spin``min` 必须为 `0.0`** —— E3-① 实测:大负数 `min` 与细 `step` 相差数量级时,`Range``round((v-min)/step)*step+min` 产生抵消误差(`0.01 → 0.00999999999476`),会把噪声写进权威数据文件,且容差断言察觉不到。
### 2.7 权威文档补录
定稿后把属性购买补进 `numerical_design.md` §1.2 的**消耗清单**(现仅法术/法杖/刷新三项):各属性的首购价与成长曲线、「按累计购买次数递增、不随波次重置」的规则、以及 `cast_delay_mod` 的饱和点上限。
这是本设计的**组成部分而非可选项** —— §1.4 查明该清单缺失属性购买,补上它才闭环。
## 3. 影响文件
| 文件 | 改动 |
| :-- | :-- |
| `scripts/domain/price_formula.gd` | **新增** —— 定价模块(+ `.gd.uid` |
| `data/attributes.json` | 四个属性各加 `shop` 段 |
| `scripts/autoloads/shop_manager.gd` | `_attr_purchases` + `MOD_SOURCE_SHOP_C` + 购买/校验/`_apply_attr_purchases` + `reset` |
| `scripts/domain/combat/combat_manager.gd` | 存档读写接入 `_attr_purchases` |
| `scripts/autoloads/profile_manager.gd` | `SCHEMA_VERSION` 2 → 3 |
| `scenes/main/combat_s2.gd` | 商店「属性」区 UI |
| `addons/game_designer/attribute_tab.gd` | `shop` 段五字段 |
| `docs/design/numerical_design.md` | §1.2 消耗清单补属性购买 |
## 4. 验收标准
**纯函数(不启动游戏,`ResourceLoader.load(..., CACHE_MODE_IGNORE)` 加载)**
1. `geometric``{base:60, growth:1.15}` + `purchased 0/1/2``60 / 69 / 79`
2. `linear``{base:20, growth:10}` + `0/1/2``20 / 30 / 40`(与刷新费用 P6-N23 同构,作交叉验证)。
3. `flat`:任意 `purchased` 恒等于 `price_base`
4. `purchased = 0` 恒返回 `price_base`(三种曲线皆然)。
5. 未知 `curve` 字符串 → `push_error` 且回退 `geometric`**用唯一标记值 + Debugger 错误树断言报错真的发出**)。
6. 返回类型为 `int`,且下限 1。
**运行时**
7. 买 3 次 `hp_max` → 生效 `hp_max = 100 × 1.1³ = 133.1`(**验证连乘而非线性的 130**)。
8. 金币按几何序列扣除(60 → 69 → 79),且 `_attr_purchases` 与生效值一致。
9.`soft` 后按钮禁用且 `buy` API 拒绝。
10. `cast_delay_mod` 买到 `soft`(0.1) 即禁用;并断言此时**尚未饱和**(`wand_basic` 实测 3 帧/次 = 20 /s,非 1 帧)—— 证明 §2.3 的订正成立、无需逐杖饱和判定。
11. `cpu_limit``shop.mode` 若误配为 `pct``add_modifier` 拒绝并 `push_error``add_int` 硬约束)。
12. 存档 → 回读:次数与生效值均还原;旧存档(无该键)不崩、视为零购买。
13. `reset_for_run` / `ShopManager.reset()` 后次数与加成均清零。
14.`"core"` 来源共存:换杖不影响货架 C 的加成,反之亦然。
**规范**
15. 设计器 `shop` 段回读往返**逐位相等**(不用容差 —— E3-① 栽过三次);`mode`/`curve` 存 key 不存双语显示串。
16.`shop` 段的属性不上架。
17. `validate_script` 通过(`price_formula.gd``class_name`,须用 `CACHE_MODE_IGNORE` 验证,`validate_script` 对其必然假阴性)。
## 5. 非目标(YAGNI
- **B 类 7 个属性变为可售** —— 各有前置(§1.2),前置解决后加 `shop` 段即可,零代码。
- **`mana_regen` / `mana_leech` 迁入属性框架** —— 它们是权威表漏掉的真实属性、也是最可能的下一批候选,但迁移是独立工作(`mana_max` 还牵连「与法杖取 Min」的未决语义)。
- **出售退款 G5** —— 子计划 ④。本期的 `_attr_purchases` 形状已为其铺好(次数减一后重算即可)。
- **货架 B 核心抽取** —— 子计划 ③。`PriceFormula` 已设计为可直接复用。
- **随机属性词条 / 稀有大幅提升** —— 固定轨先行,将来觉得缺惊喜再议。
- **以太(跨局)购买属性** —— 语义不符(§1.3)。
- **E7-⑥ 计算模块化重构** —— 已独立立项(路线图 E7-⑥)。`PriceFormula` 会成为第二个已模块化的例子,与 `AttributeFormula` 一起确立命名/签名/测试惯例。