Files
spellforge/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md
T
joywayerandClaude Opus 5 68e3edbc7d docs: 订正 pct 合并公式——必须按 combine 分支,inverse 用 1−(1−step)^n
spec §2.4 与计划 Task2 写的 merged = (1+step)^n − 1 只对 hybrid 成立
(AttributeFormula 对 hybrid 用 f=1+v)。inverse 用的是 f=1−v,于是
cast_delay_mod 实际算成 f = 2−1.1^n 而非意图中的 0.9^n:
  n=2 应 0.81 实得 0.79;n=7 应 0.478 实得 0.0513
后果是该属性在第 7 次购买就撞 soft 封顶(设计意图约 22 次),
可购买次数砍到 1/3、边际收益曲线整体错误,且零诊断。

评审真机连续购买 7 次坐实。此为 spec 源头错误,非实现偏差。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:17:35 +08:00

267 lines
18 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.
# 货架 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 PriceCurve { 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) -> PriceCurve
```
| 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>
# pct 合并须按 combine 分支:AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1v
var merged: float = (1.0 - pow(1.0 - step, n)) if (mode == "pct" and combine == "inverse") else ((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/autoloads/profile_manager.gd` | `SCHEMA_VERSION` 2 → 3`_collect_run_data` 写入、`apply_run` 回读(**顺序必须早于 `load_save_data`**,见 §2.5 |
| `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` 一起确立命名/签名/测试惯例。