From 24948c77f8b5d4a8de6af9d96125239ae375ee69 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 10:52:10 +0800 Subject: [PATCH 01/20] =?UTF-8?q?docs(plan):=20=E9=A2=84=E6=A3=80=E8=AE=A2?= =?UTF-8?q?=E6=AD=A3=E2=80=94=E2=80=94=E6=B6=88=E9=99=A4=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E5=B1=9E=E6=80=A7=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E5=85=91=E7=8E=B0=E3=80=8C=E5=8A=A0=E5=B1=9E=E6=80=A7=E9=9B=B6?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开工前扫描发现计划初稿违反了它自己的 Global Constraint:ShopManager _effective_value() 与 combat_s2 的两个辅助各写了一个 match 硬编码四个属性名, 外加 4 个 ATTR_* 的 i18n 键。第 5 个属性加 shop 段后会「UI 生成了行、 但生效值读出 0 且名字是裸 id」——本特性的立身之本直接破功。 改为:PlayerStats 维护 _attr_effective 冷路径视图 + get_attr_value(id) (热路径仍走裸字段,零开销不受影响);UI 的显示名读 attributes.json 已有的 display_name;ShopManager 暴露 get_attr_def()。三处 match 与 4 个 i18n 键全部删除。 Co-Authored-By: Claude Opus 5 --- .../2026-07-31-shelf-c-attribute-shop.md | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 7ee7c9b..f723475 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -206,12 +206,45 @@ EOF ### Task 2: `shop` 段数据 + `ShopManager` 货架 C 状态与购买 **Files:** +- Modify: `scripts/autoloads/player_stats.gd`(通用访问器,见 Step 0) - Modify: `data/attributes.json`(四属性各加 `shop` 段) - Modify: `scripts/autoloads/shop_manager.gd` **Interfaces:** - Consumes: `PriceFormula.compute(spec, purchased) -> int`(Task 1);`PlayerStats.add_modifier(attr_id, mode, value, source)` / `remove_modifiers_from(source)` / `spend_gold(amount) -> bool` -- Produces: `ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`;`get_sellable_attrs() -> Array[String]`;`get_attr_price(attr_id: String) -> int`;`get_attr_purchases(attr_id: String) -> int`;`can_buy_attribute(attr_id: String) -> String`(返回 `""` 表示可买,否则为禁用原因);`buy_attribute(attr_id: String) -> bool`;`get_attr_purchases_save() -> Dictionary`;`apply_attr_purchases_save(d: Dictionary) -> void` +- Produces: `PlayerStats.get_attr_value(attr_id: String) -> float`;`ShopManager.MOD_SOURCE_SHOP_C: String = "shop_c"`;`get_sellable_attrs() -> Array[String]`;`get_attr_def(attr_id: String) -> Dictionary`;`get_attr_price(attr_id: String) -> int`;`get_attr_purchases(attr_id: String) -> int`;`can_buy_attribute(attr_id: String) -> String`(返回 `""` 表示可买,否则为禁用原因);`buy_attribute(attr_id: String) -> bool`;`get_attr_purchases_save() -> Dictionary`;`apply_attr_purchases_save(d: Dictionary) -> void` + +- [ ] **Step 0: `PlayerStats` 加通用生效值访问器(消除硬编码属性名)** + +> 📌 **预检发现(2026-07-31,开工前)**:本计划初稿在 `ShopManager._effective_value()` 与 `combat_s2` 的两个辅助里各写了一个 `match attr_id:` 硬编码四个属性名。**那违反本计划自己的 Global Constraint(纯数据驱动、代码不含硬编码副本),也违反本特性的立身之本「加可售属性 = 加 JSON 段,零代码」** —— 第 5 个属性加 `shop` 段后,`get_sellable_attrs()` 会返回它、UI 会生成那一行,但生效值读出 `0.0` 并 `push_error`、名字显示成裸 id。故先加通用访问器,三处 `match` 全部删除。 + +`player_stats.gd`:在 `_modifiers` 声明(约 :39)之后新增 +```gdscript +var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取 +``` + +`_recompute_attrs()` 内,在写回裸字段之后、`stats_changed.emit()` 之前追加: +```gdscript + # 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。 + # 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。 + _attr_effective = { + "cpu_limit": float(cpu_limit), + "move_speed": move_speed, + "cast_delay_mod": cast_delay_mod, + "hp_max": hp_max, + } +``` +文件末尾新增: +```gdscript +## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。 +func get_attr_value(attr_id: String) -> float: + if not _attr_effective.has(attr_id): + push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id) + return 0.0 + return float(_attr_effective[attr_id]) +``` + +> 注:`_attr_effective` 的键仍逐个列出,因为裸字段本身就是逐个声明的(热路径要求)。但**消费方**(商店、UI)自此不再硬编码属性名 —— 新增属性时只需在此表加一行,而不是在三个文件里各改一个 `match`。这是「热路径零开销」与「消费端数据驱动」之间的必要接缝。 - [ ] **Step 1: `attributes.json` 四属性加 `shop` 段** @@ -302,20 +335,15 @@ func _at_soft_cap(attr_id: String, d: Dictionary) -> bool: var soft: float = float(d.get("soft", 0.0)) if soft <= 0.0: return false - var cur: float = _effective_value(attr_id) + var cur: float = PlayerStats.get_attr_value(attr_id) if String(d.get("combine", "hybrid")) == "inverse": return cur <= soft return cur >= soft -## 读取生效值(裸字段,读取端零开销) -func _effective_value(attr_id: String) -> float: - match attr_id: - "cpu_limit": return float(PlayerStats.cpu_limit) - "move_speed": return PlayerStats.move_speed - "cast_delay_mod": return PlayerStats.cast_delay_mod - "hp_max": return PlayerStats.hp_max - push_error("ShopManager: 未知属性「%s」,无法读取生效值" % attr_id) - return 0.0 +## 属性定义(供 UI 读 display_name 等,避免在 UI 侧再复制一份属性名表) +func get_attr_def(attr_id: String) -> Dictionary: + var d = _attr_def.get(attr_id, {}) + return d if d is Dictionary else {} func buy_attribute(attr_id: String) -> bool: var reason: String = can_buy_attribute(attr_id) @@ -544,38 +572,29 @@ var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}] var reason: String = ShopManager.can_buy_attribute(id) var price: int = ShopManager.get_attr_price(id) var n: int = ShopManager.get_attr_purchases(id) - row["label"].text = tr("SHOP_ATTR_ROW") % [_attr_display_name(id), _attr_effective_text(id), n] + var disp: String = String(ShopManager.get_attr_def(id).get("display_name", id)) + row["label"].text = tr("SHOP_ATTR_ROW") % [disp, "%.2f" % PlayerStats.get_attr_value(id), n] row["button"].text = (tr("SHOP_ATTR_BUY") % price) if reason.is_empty() else reason row["button"].disabled = not reason.is_empty() ``` -新增两个辅助与回调: +新增回调: ```gdscript -func _attr_display_name(id: String) -> String: - match id: - "cpu_limit": return tr("ATTR_CPU_LIMIT") - "move_speed": return tr("ATTR_MOVE_SPEED") - "cast_delay_mod": return tr("ATTR_CAST_DELAY") - "hp_max": return tr("ATTR_HP_MAX") - return id - -func _attr_effective_text(id: String) -> String: - match id: - "cpu_limit": return str(PlayerStats.cpu_limit) - "move_speed": return "%.0f" % PlayerStats.move_speed - "cast_delay_mod": return "%.2f" % PlayerStats.cast_delay_mod - "hp_max": return "%.0f" % PlayerStats.hp_max - return "-" - func _on_buy_attr_pressed(id: String) -> void: if ShopManager.buy_attribute(id): _refresh_shop_ui() ``` + +> 📌 **预检订正**:初稿在此写了 `_attr_display_name()` 与 `_attr_effective_text()` 两个 `match id:` 辅助,**各硬编码一遍四个属性名** —— 而 `display_name` 本就在 `attributes.json` 里、生效值有 `PlayerStats.get_attr_value()`。两个辅助全部删除,改为读数据。第 5 个属性自此无需改动本文件。 +> 统一用 `%.2f` 显示:`cast_delay_mod` 需要两位小数,而 `hp_max`/`move_speed` 显示 `133.10` 也可接受 —— 逐属性定制格式又会引回一张硬编码表。若日后确需,把格式串放进 `attributes.json` 而非代码。 刷新函数名**已核实为 `_refresh_shop_ui()`**(`combat_s2.gd:413` 的 `_on_stats_changed` 调用它)。 - [ ] **Step 4: 补 i18n 键(4 语言)** -`translations/` 下 `zh_CN.po` / `zh_TW.po` / `en.po` / `ja.po` **各加 6 个键**,键集必须一致(项目已验证 4 语言零缺键): -`SHOP_ATTR_TITLE`、`SHOP_ATTR_ROW`(格式 `%s 当前 %s 已购 %d 次`)、`SHOP_ATTR_BUY`(`购买 %dG`)、`ATTR_CPU_LIMIT`、`ATTR_MOVE_SPEED`、`ATTR_CAST_DELAY`、`ATTR_HP_MAX`。 +`translations/` 下 `zh_CN.po` / `zh_TW.po` / `en.po` / `ja.po` **各加 3 个键**,键集必须一致(项目已验证 4 语言零缺键): +`SHOP_ATTR_TITLE`、`SHOP_ATTR_ROW`(格式 `%s 当前 %s 已购 %d 次`)、`SHOP_ATTR_BUY`(`购买 %dG`)。 + +> 📌 **预检订正**:初稿还要求加 `ATTR_CPU_LIMIT` 等 4 个属性名键 —— 那是硬编码属性表的另一种形态(加第 5 个属性就要改 4 个 `.po`)。属性显示名读 `attributes.json` 的 `display_name`(已是「中文 English」双语),故这 4 个键不需要。 +> 代价如实说明:`display_name` 不经 `tr()`,即属性名不随语言切换 —— 但这与法术/核心/状态的 `display_name` 现状一致(路线图 E7-③「内容名 tr 化」是统一处理这批债务的独立小项),**本期不制造新的例外**。 ⚠️ `can_buy_attribute` 返回的禁用原因目前是**中文裸串**(在 `shop_manager.gd` 里)。本期先如此;i18n 债务(内容名 tr 化)是路线图 E7-③ 的独立小项,**不在本期扩大范围**,但要在 `can_buy_attribute` 的注释里注明这一点。 @@ -585,7 +604,7 @@ Run: godot-mcp-pro `validate_script` on `res://scenes/main/combat_s2.gd` — `va Run: godot-mcp-pro `execute_editor_script` 校验 4 语言键集一致: ```gdscript -var keys := ["SHOP_ATTR_TITLE", "SHOP_ATTR_ROW", "SHOP_ATTR_BUY", "ATTR_CPU_LIMIT", "ATTR_MOVE_SPEED", "ATTR_CAST_DELAY", "ATTR_HP_MAX"] +var keys := ["SHOP_ATTR_TITLE", "SHOP_ATTR_ROW", "SHOP_ATTR_BUY"] var fails := 0 var bad := [] for loc in ["zh_CN", "zh_TW", "en", "ja"]: From c6e89a0787c8c511ff49c21eef6447fc321a97b7 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 10:55:35 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat(shop):=20PriceFormula=20=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94geometric=20/=20li?= =?UTF-8?q?near=20/=20flat=20=E4=B8=89=E6=9B=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定价集中于单一纯静态模块,无状态零依赖,故可脱离游戏进程单元断言 (与 AttributeFormula 同构)。模块不认识「属性/武器/装备」,只认识 {price_base, price_growth, curve},划分点在数据里——故货架 B 与出售退款 可直接复用,不必各写一套。 返回值下限 1:免费购买无意义,且 0 价会让「买不起」的判定失效。 偏离简报字面代码一处:枚举由 `Curve` 改名 `PriceCurve`。Godot 4.7.1 拒绝声明与引擎全局类同名的嵌套枚举("member Curve shadows a native class",与 class_name 是否注册无关),简报/设计文档中的 `enum Curve` 在本引擎版本下无法编译,属简报代码本身的缺陷而非誊抄误差。 Co-Authored-By: Claude Opus 5 --- scripts/domain/price_formula.gd | 45 +++++++++++++++++++++++++++++ scripts/domain/price_formula.gd.uid | 1 + 2 files changed, 46 insertions(+) create mode 100644 scripts/domain/price_formula.gd create mode 100644 scripts/domain/price_formula.gd.uid diff --git a/scripts/domain/price_formula.gd b/scripts/domain/price_formula.gd new file mode 100644 index 0000000..a7622a5 --- /dev/null +++ b/scripts/domain/price_formula.gd @@ -0,0 +1,45 @@ +## PriceFormula — 价格计算的唯一实现处 +## 纯静态函数:无状态、不依赖 ShopManager / PlayerStats / 场景树,故可脱离游戏进程单元测试 +## 本模块**不认识**「属性/武器/装备」,只认识 {price_base, price_growth, curve} —— +## 划分点在数据里,故货架 B(核心抽取)与子计划 ④(出售退款)可直接复用。 +## 权威来源:docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md §2.1 +class_name PriceFormula +extends RefCounted + +## 命名为 PriceCurve 而非 Curve:Godot 引擎自带全局类 `Curve`(曲线资源), +## 嵌套枚举若同名会被解析器拒绝("member Curve shadows a native class"), +## 与 class_name 是否注册无关,故不能叫 Curve。此为本任务对简报字面代码的 +## 唯一必要偏离,详见 task-1-report.md。 +enum PriceCurve { GEOMETRIC, LINEAR, FLAT } + +const _CURVE_BY_NAME: Dictionary[String, PriceCurve] = { + "geometric": PriceCurve.GEOMETRIC, + "linear": PriceCurve.LINEAR, + "flat": PriceCurve.FLAT, +} + +## JSON 的 curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC +static func curve_from_string(s: String) -> PriceCurve: + if _CURVE_BY_NAME.has(s): + return _CURVE_BY_NAME[s] + push_error("PriceFormula: 未知 curve「%s」,回退 geometric" % s) + return PriceCurve.GEOMETRIC + +## 唯一的价格入口 +## spec —— 数据文件里的定价段,读 price_base / price_growth / curve +## purchased —— 已购次数(0 = 首次购买) +## 返回 int(金币是整数),下限 1:免费购买无意义,且 0 价会让「买不起」的判定失效 +static func compute(spec: Dictionary, purchased: int) -> int: + var base: float = float(spec.get("price_base", 0.0)) + var growth: float = float(spec.get("price_growth", 1.0)) + var n: int = maxi(purchased, 0) + var curve: PriceCurve = curve_from_string(String(spec.get("curve", "geometric"))) + var raw: float = 0.0 + match curve: + PriceCurve.LINEAR: + raw = base + float(n) * growth + PriceCurve.FLAT: + raw = base + _: + raw = base * pow(growth, float(n)) + return maxi(roundi(raw), 1) diff --git a/scripts/domain/price_formula.gd.uid b/scripts/domain/price_formula.gd.uid new file mode 100644 index 0000000..3fc0196 --- /dev/null +++ b/scripts/domain/price_formula.gd.uid @@ -0,0 +1 @@ +uid://b4h1n0puafawv From 359da9f24fe3858ad8e11ad5b7a1ee11978d358f Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 10:56:57 +0800 Subject: [PATCH 03/20] =?UTF-8?q?docs:=20=E8=AE=A2=E6=AD=A3=20enum=20Curve?= =?UTF-8?q?=20=E2=86=92=20PriceCurve=EF=BC=88=E4=B8=8E=20Godot=20=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20Curve=20=E8=B5=84=E6=BA=90=E7=B1=BB=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 实现时发现:enum Curve 在 4.7.1 解析期即报 "member Curve shadows a native class"——与引擎内置的 Curve 资源类同名。 实现者做了独立最小复现,确认与 class_name 无关。 spec §2.1 与计划 Task 1 的代码块都写着 enum Curve,若不订正,Task 2 的简报 会从计划里抄到错误的类型名。下游一律用 PriceFormula.PriceCurve。 Co-Authored-By: Claude Opus 5 --- .../2026-07-31-shelf-c-attribute-shop.md | 22 +++++++++---------- ...026-07-31-shelf-c-attribute-shop-design.md | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index f723475..e152cca 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -61,7 +61,7 @@ Expected: `Switched to a new branch 'feat/shelf-c-attribute-shop'` **Interfaces:** - Consumes: 无(零依赖) -- Produces: `PriceFormula.compute(spec: Dictionary, purchased: int) -> int`;`PriceFormula.curve_from_string(s: String) -> PriceFormula.Curve`;`enum Curve { GEOMETRIC, LINEAR, FLAT }` +- Produces: `PriceFormula.compute(spec: Dictionary, purchased: int) -> int`;`PriceFormula.curve_from_string(s: String) -> PriceFormula.Curve`;`enum PriceCurve { GEOMETRIC, LINEAR, FLAT }` > 纯函数,**先写断言、看它失败、再实现**。断言经 `execute_editor_script` 执行(项目无测试框架)。 @@ -86,20 +86,20 @@ Create `scripts/domain/price_formula.gd`: class_name PriceFormula extends RefCounted -enum Curve { GEOMETRIC, LINEAR, FLAT } +enum PriceCurve { GEOMETRIC, LINEAR, FLAT } -const _CURVE_BY_NAME: Dictionary[String, Curve] = { - "geometric": Curve.GEOMETRIC, - "linear": Curve.LINEAR, - "flat": Curve.FLAT, +const _CURVE_BY_NAME: Dictionary[String, PriceCurve] = { + "geometric": PriceCurve.GEOMETRIC, + "linear": PriceCurve.LINEAR, + "flat": PriceCurve.FLAT, } ## JSON 的 curve 字符串 → 枚举;未知值 push_error 并回退 GEOMETRIC -static func curve_from_string(s: String) -> Curve: +static func curve_from_string(s: String) -> PriceCurve: if _CURVE_BY_NAME.has(s): return _CURVE_BY_NAME[s] push_error("PriceFormula: 未知 curve「%s」,回退 geometric" % s) - return Curve.GEOMETRIC + return PriceCurve.GEOMETRIC ## 唯一的价格入口 ## spec —— 数据文件里的定价段,读 price_base / price_growth / curve @@ -109,12 +109,12 @@ static func compute(spec: Dictionary, purchased: int) -> int: var base: float = float(spec.get("price_base", 0.0)) var growth: float = float(spec.get("price_growth", 1.0)) var n: int = maxi(purchased, 0) - var curve: Curve = curve_from_string(String(spec.get("curve", "geometric"))) + var curve: PriceCurve = curve_from_string(String(spec.get("curve", "geometric"))) var raw: float = 0.0 match curve: - Curve.LINEAR: + PriceCurve.LINEAR: raw = base + float(n) * growth - Curve.FLAT: + PriceCurve.FLAT: raw = base _: raw = base * pow(growth, float(n)) diff --git a/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md index 0943bf1..05a4d11 100644 --- a/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md +++ b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md @@ -93,14 +93,14 @@ class_name PriceFormula extends RefCounted -enum Curve { GEOMETRIC, LINEAR, FLAT } +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) -> Curve +static func curve_from_string(s: String) -> PriceCurve ``` | curve | 公式 | From 6aeadb84b8ba35acfa0ee84fc8b97921ef805068 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:05:51 +0800 Subject: [PATCH 04/20] =?UTF-8?q?fix(shop):=20PriceFormula=20=E5=A4=A7?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E6=AC=A1=E6=95=B0=E6=BA=A2=E5=87=BA=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=A1=8C=E9=99=B7=E4=B8=BA=201=EF=BC=9B=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit geometric 曲线在 purchased 较大时(如 n=300,base=60 growth=1.15)raw 虽仍是合法有限 double(约 9.7e19),但已超出 int64 安全范围,roundi() 对此行为未定义/环绕,经 maxi(...,1) 静默塌陷成 1——方向与「买得越多越 贵」相反,且零诊断。仅判断 is_finite(raw) 测不出这种情况(double 本身 溢出为 INF 要到 n≈5077 才发生,晚于 int64 溢出很多),故改为 `not is_finite(raw) or raw > 9.0e15` 双重判据,触发时 push_error 并钳 到统一上限,不再依赖具体常数断言(新增用例只断言单调性与「不再塌陷回 归」)。 同时补齐 docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md:64 遗漏的 `PriceFormula.Curve` → `PriceFormula.PriceCurve` 同步(enum 部分先前已 改,返回类型标注漏改),并全仓复核确认无其它残留。 Co-Authored-By: Claude Opus 5 --- docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md | 2 +- scripts/domain/price_formula.gd | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index e152cca..83689c4 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -61,7 +61,7 @@ Expected: `Switched to a new branch 'feat/shelf-c-attribute-shop'` **Interfaces:** - Consumes: 无(零依赖) -- Produces: `PriceFormula.compute(spec: Dictionary, purchased: int) -> int`;`PriceFormula.curve_from_string(s: String) -> PriceFormula.Curve`;`enum PriceCurve { GEOMETRIC, LINEAR, FLAT }` +- Produces: `PriceFormula.compute(spec: Dictionary, purchased: int) -> int`;`PriceFormula.curve_from_string(s: String) -> PriceFormula.PriceCurve`;`enum PriceCurve { GEOMETRIC, LINEAR, FLAT }` > 纯函数,**先写断言、看它失败、再实现**。断言经 `execute_editor_script` 执行(项目无测试框架)。 diff --git a/scripts/domain/price_formula.gd b/scripts/domain/price_formula.gd index a7622a5..630fa3e 100644 --- a/scripts/domain/price_formula.gd +++ b/scripts/domain/price_formula.gd @@ -42,4 +42,14 @@ static func compute(spec: Dictionary, purchased: int) -> int: raw = base _: raw = base * pow(growth, float(n)) + # raw 可能在两个不同的地方失控:pow() 把 double 本身推到 INF(n 极大,如 geometric + # n≈5077+),或者 raw 仍是合法有限 double,但早已超出 int64 可安全表示的范围(如 + # geometric n=300 时 raw≈9.7e19——finite 但 > INT64_MAX≈9.22e18)。后者 is_finite() + # 测不出来,roundi() 对超范围 float 的行为是未定义/环绕,曾亲测把它环绕成极小/负值, + # 再经 maxi(...,1) 静默塌陷成 1——方向与"买得越多越贵"完全相反。故用同一个安全阈值 + # 9.0e15(远小于 INT64_MAX,远超任何合理金币量)同时兼答两种情况。 + const _PRICE_CLAMP: float = 9.0e15 + if not is_finite(raw) or raw > _PRICE_CLAMP: + push_error("PriceFormula: 价格溢出(base=%f growth=%f purchased=%d),已钳到上限" % [base, growth, n]) + raw = _PRICE_CLAMP return maxi(roundi(raw), 1) From 622da4a18e9b2b25e41016b28baac2763eeba3ff Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:11:08 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat(shop):=20=E8=B4=A7=E6=9E=B6=20C=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E8=B4=AD=E4=B9=B0=E2=80=94=E2=80=94?= =?UTF-8?q?shop=20=E6=AE=B5=E9=A9=B1=E5=8A=A8=E5=8F=AF=E5=94=AE=E9=9B=86?= =?UTF-8?q?=E5=90=88=EF=BC=8C=E5=94=AF=E4=B8=80=E5=86=99=E5=85=A5=E7=82=B9?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=E5=8A=A0=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可售集合纯数据驱动:attributes.json 的 shop 段缺失即不可售,将来解除 7 个 被阻塞属性的前置后只需加一段 JSON,零代码。 PlayerStats 新增通用生效值访问器 get_attr_value(attr_id),消除商店/UI 侧 本应出现的三处硬编码 match attr_id;_attr_effective 与裸字段在同一处 (_recompute_attrs)更新,避免两者不同步导致显示值与生效值脱节。 _attr_purchases 只存次数;每属性至多一条 _modifiers、value 为合并值 (pct 下 n 次 +step 等价于单条 (1+step)^n − 1)。这样出售退款只需次数减一后 重算,不必给 PlayerStats 新增按条撤销的 API。 所有改动次数的路径都收在 _apply_attr_purchases()——两份状态不同步会产生 「显示买了 3 次但加成只有 2 次」且无任何诊断,收敛写入点是唯一防线。 Co-Authored-By: Claude Opus 5 --- data/attributes.json | 8 +-- scripts/autoloads/player_stats.gd | 16 +++++ scripts/autoloads/shop_manager.gd | 111 ++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/data/attributes.json b/data/attributes.json index 2e83751..470ed55 100644 --- a/data/attributes.json +++ b/data/attributes.json @@ -1,6 +1,6 @@ { - "cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int" }, - "move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid" }, - "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse" }, - "hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" } + "cpu_limit": { "display_name": "运算力 cpu_limit", "base": 0.0, "soft": 20.0, "hard": 50.0, "combine": "add_int", "shop": { "mode": "flat", "step": 1, "price_base": 120, "price_growth": 1.30, "curve": "geometric" } }, + "move_speed": { "display_name": "移动速度 move_speed", "base": 200.0, "soft": 600.0, "hard": 800.0, "combine": "hybrid", "shop": { "mode": "pct", "step": 0.08, "price_base": 70, "price_growth": 1.15, "curve": "geometric" } }, + "cast_delay_mod": { "display_name": "施法延迟 cast_delay_mod", "base": 1.0, "soft": 0.1, "hard": 0.05, "combine": "inverse", "shop": { "mode": "pct", "step": 0.10, "price_base": 90, "price_growth": 1.20, "curve": "geometric" } }, + "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" } } } diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd index 695a577..b76643f 100644 --- a/scripts/autoloads/player_stats.gd +++ b/scripts/autoloads/player_stats.gd @@ -37,6 +37,7 @@ var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now # 属性定义与加成来源(非热路径) var _attr_def: Dictionary = {} # attributes.json 全量定义,只读 var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}] +var _attr_effective: Dictionary = {} # attr_id → 生效值;供冷路径(商店/UI/设计器)通用读取 # ── 魔力(MVP)───────────────────────────────────────── var mana: float = 100.0 @@ -233,6 +234,14 @@ func _recompute_attrs() -> void: cast_delay_mod = _compute_attr("cast_delay_mod") hp_max = _compute_attr("hp_max") hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max + # 冷路径通用视图:热路径(move_speed 每物理帧)仍走裸字段,此表只服务商店/UI 等按 id 取值的场景。 + # 必须与裸字段在同一处更新——两者不同步会让商店显示的值与实际生效值不一致且无诊断。 + _attr_effective = { + "cpu_limit": float(cpu_limit), + "move_speed": move_speed, + "cast_delay_mod": cast_delay_mod, + "hp_max": hp_max, + } stats_changed.emit() func _compute_attr(attr_id: String) -> float: @@ -247,3 +256,10 @@ func _compute_attr(attr_id: String) -> float: assert(false, "PlayerStats: 缺少属性「%s」——生效值将为 0,运行时 push_error 不可见" % attr_id) return 0.0 return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d) + +## 按 id 取生效值(冷路径)。热路径请直接读裸字段(move_speed 等),本函数有字典查找开销。 +func get_attr_value(attr_id: String) -> float: + if not _attr_effective.has(attr_id): + push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id) + return 0.0 + return float(_attr_effective[attr_id]) diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index 3d34f27..d600604 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -10,12 +10,20 @@ const SLOT_COUNT: int = 3 const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用 const REROLL_STEP: int = 10 # 每次递增价格 +## 货架 C 加成来源标识。必须用常量而非裸字面量:来源串在 remove/add 两侧必须逐字相同—— +## remove 那侧打错一个字符,旧份额就不会被撤销而是逐次累积(理由同 PlayerStats.MOD_SOURCE_CORE) +const MOD_SOURCE_SHOP_C: String = "shop_c" +const ATTRIBUTES_JSON: String = "res://data/attributes.json" + var current_slots: Array = [] # Array[SpellNode] (null 表示已售出) var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置) var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07) var _rng: RandomNumberGenerator = RandomNumberGenerator.new() +var _attr_purchases: Dictionary = {} # attr_id(String) → 已购次数(int);唯一写入点 _apply_attr_purchases() +var _attr_def: Dictionary = {} # attributes.json 全量定义,只读 func _ready() -> void: + _load_attr_definitions() EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete) func _on_wave_complete(_payload: Dictionary) -> void: @@ -86,7 +94,110 @@ func close_shop() -> void: func get_shop_seed() -> int: return _shop_seed +# ── 货架 C:属性购买 ────────────────────────────────────── +func _load_attr_definitions() -> void: + if not FileAccess.file_exists(ATTRIBUTES_JSON): + push_error("ShopManager: 缺少 %s" % ATTRIBUTES_JSON) + return + var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON)) + if not (parsed is Dictionary): + push_error("ShopManager: %s 格式错误(应为对象)" % ATTRIBUTES_JSON) + return + for k in parsed: + if not (parsed[k] is Dictionary): + push_error("ShopManager: attributes.json 的「%s」应为对象,整个文件已拒绝加载" % k) + return + _attr_def = parsed + +## 可售属性 = 带 shop 段的属性。缺段即不可售(数据驱动,加属性零代码) +func get_sellable_attrs() -> Array[String]: + var out: Array[String] = [] + for id in _attr_def: + if _attr_def[id].get("shop", null) is Dictionary: + out.append(String(id)) + out.sort() + return out + +func get_attr_purchases(attr_id: String) -> int: + return int(_attr_purchases.get(attr_id, 0)) + +func get_attr_price(attr_id: String) -> int: + var sp = _attr_def.get(attr_id, {}).get("shop", null) + if not (sp is Dictionary): + return 0 + return PriceFormula.compute(sp, get_attr_purchases(attr_id)) + +## 返回 "" 表示可买;否则为禁用原因(UI 直接显示,不要只灰掉按钮) +func can_buy_attribute(attr_id: String) -> String: + var d: Dictionary = _attr_def.get(attr_id, {}) + var sp = d.get("shop", null) + if not (sp is Dictionary): + return "该属性不可购买" + if _at_soft_cap(attr_id, d): + return "已达上限" + if PlayerStats.gold < get_attr_price(attr_id): + return "金币不足" + return "" + +## 已达 soft 上限?soft 是「常规来源可达上限」(E3-① 保留该字段正为此) +## inverse 属性越低越好,故方向相反 +func _at_soft_cap(attr_id: String, d: Dictionary) -> bool: + var soft: float = float(d.get("soft", 0.0)) + if soft <= 0.0: + return false + var cur: float = PlayerStats.get_attr_value(attr_id) + if String(d.get("combine", "hybrid")) == "inverse": + return cur <= soft + return cur >= soft + +## 属性定义(供 UI 读 display_name 等,避免在 UI 侧再复制一份属性名表) +func get_attr_def(attr_id: String) -> Dictionary: + var d = _attr_def.get(attr_id, {}) + return d if d is Dictionary else {} + +func buy_attribute(attr_id: String) -> bool: + var reason: String = can_buy_attribute(attr_id) + if not reason.is_empty(): + return false + var cost: int = get_attr_price(attr_id) + if not PlayerStats.spend_gold(cost): + return false + _attr_purchases[attr_id] = get_attr_purchases(attr_id) + 1 + _apply_attr_purchases() + shop_refreshed.emit() + return true + +## 唯一写入点:所有改动 _attr_purchases 的路径(购买 / 出售退款 / 存档回读 / reset) +## 都必须经此重建加成。两份状态不同步会产生「显示买了 3 次但加成只有 2 次」且无任何诊断 +func _apply_attr_purchases() -> void: + PlayerStats.remove_modifiers_from(MOD_SOURCE_SHOP_C) + for id in _attr_purchases: + var n: int = int(_attr_purchases[id]) + if n <= 0: + continue + var sp = _attr_def.get(id, {}).get("shop", null) + if not (sp is Dictionary): + push_error("ShopManager: 「%s」有购买记录但无 shop 段,加成已跳过" % id) + continue + var mode: String = String(sp.get("mode", "flat")) + var step: float = float(sp.get("step", 0.0)) + # pct 合并:n 次 +step 在连乘下等价于单条 (1+step)^n − 1 + var merged: float = (pow(1.0 + step, float(n)) - 1.0) if mode == "pct" else (step * float(n)) + PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C) + +## 存档:只存次数,加成是派生物(回读时经 _apply_attr_purchases 重建) +func get_attr_purchases_save() -> Dictionary: + return _attr_purchases.duplicate() + +func apply_attr_purchases_save(d: Dictionary) -> void: + _attr_purchases.clear() + for k in d: + _attr_purchases[String(k)] = int(d[k]) + _apply_attr_purchases() + func reset() -> void: current_slots.clear() reroll_count = 0 _shop_seed = 0 + _attr_purchases.clear() + _apply_attr_purchases() # 撤销 shop_c 加成(PlayerStats.reset_for_run 已清 _modifiers,此处保证独立调用时也正确) From 25bdc83c93701b42833255ca4e7fceddc83bd3a0 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:12:17 +0800 Subject: [PATCH 06/20] =?UTF-8?q?docs(plan):=20Task2=20Step6=20=E7=9A=84?= =?UTF-8?q?=20git=20add=20=E8=A1=A5=E4=B8=8A=20player=5Fstats.gd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预检时新增的 Step 0(PlayerStats 通用访问器)改了该文件,但当时没同步更新 Step 6 的提交命令。实现者发现并正确地把它纳入提交。 --- docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 83689c4..371721b 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -423,7 +423,7 @@ Expected: `FAILS=0 []` - [ ] **Step 6: 提交** ```bash -git add data/attributes.json scripts/autoloads/shop_manager.gd +git add scripts/autoloads/player_stats.gd data/attributes.json scripts/autoloads/shop_manager.gd git commit -F- <<'EOF' feat(shop): 货架 C 状态与购买——shop 段驱动可售集合,唯一写入点重建加成 From 68e3edbc7d8873b3620b801bb0d07d15a9e45b71 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:17:35 +0800 Subject: [PATCH 07/20] =?UTF-8?q?docs:=20=E8=AE=A2=E6=AD=A3=20pct=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=85=AC=E5=BC=8F=E2=80=94=E2=80=94=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E6=8C=89=20combine=20=E5=88=86=E6=94=AF=EF=BC=8Cinver?= =?UTF-8?q?se=20=E7=94=A8=201=E2=88=92(1=E2=88=92step)^n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md | 12 ++++++++++-- .../2026-07-31-shelf-c-attribute-shop-design.md | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 371721b..caddae7 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -371,8 +371,16 @@ func _apply_attr_purchases() -> void: continue var mode: String = String(sp.get("mode", "flat")) var step: float = float(sp.get("step", 0.0)) - # pct 合并:n 次 +step 在连乘下等价于单条 (1+step)^n − 1 - var merged: float = (pow(1.0 + step, float(n)) - 1.0) if mode == "pct" else (step * float(n)) + # pct 合并必须按属性的 combine 分支——AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1-v, + # 故两者的等价单条值不同。写错会让 inverse 属性(cast_delay_mod)的曲线整体偏离且零诊断。 + var combine: String = String(_attr_def.get(id, {}).get("combine", "hybrid")) + var merged: float = 0.0 + if mode == "pct": + # hybrid: 需 f=(1+step)^n → merged=(1+step)^n−1 + # inverse: 需 f=(1−step)^n → merged=1−(1−step)^n + merged = (1.0 - pow(1.0 - step, float(n))) if combine == "inverse" else (pow(1.0 + step, float(n)) - 1.0) + else: + merged = step * float(n) # flat 线性可加,与 combine 无关 PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C) ## 存档:只存次数,加成是派生物(回读时经 _apply_attr_purchases 重建) diff --git a/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md index 05a4d11..94a850f 100644 --- a/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md +++ b/docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md @@ -180,7 +180,8 @@ for id in _attr_purchases: var n: int = _attr_purchases[id] if n <= 0: continue var sp: Dictionary = - var merged: float = (pow(1.0 + step, n) - 1.0) if mode == "pct" else (step * n) + # pct 合并须按 combine 分支:AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1−v + 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) ``` From f8e02b0a0b41c99ec3127dd8bccd8f134bca6744 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:19:23 +0800 Subject: [PATCH 08/20] =?UTF-8?q?fix(shop):=20pct=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E6=8C=89=20combine=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=8F=96=E5=80=BC=EF=BC=8C=E4=BF=AE=E6=AD=A3=20inverse=20?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E7=AE=97=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_attr_purchases() 原公式 merged=(1+step)^n−1 只对 AttributeFormula 的 hybrid 分支(f=1+v)成立;inverse 分支用 f=1−v,四个可售属性里 cast_delay_mod 恰是 inverse+pct,实际把 f 算成 2−1.1^n 而非设计意图的 0.9^n——n=7 时应得 0.478,实得 ≈0.0513,可购买次数从设计的约 22 次被砍到约 7 次即撞 soft,且 全程 f≥0 走不到越界钳制分支,零诊断。 这是 Task 2 简报 spec 本身推导错误(简报只在 hybrid 下验证过等价性),非 实现偏差;评审已订正 spec 与计划(68e3edb)。现按订正后的公式补上按 combine 分支取值:hybrid 用 merged=(1+step)^n−1,inverse 用 merged=1−(1−step)^n,flat 与 combine 无关不变。 补测显式覆盖此前遗漏的 inverse+pct 组合(此前运行时验证只买过 move_speed/hp_max/cpu_limit,唯独没买过 cast_delay_mod——测试盲区精确盖住 了 bug 所在处):n=1..3/7 逐点数值比对(容差 1e-6),并确认 soft 封顶购买 次数从 ~7 恢复到 ~22;hybrid/flat 对照组数值不变。 Co-Authored-By: Claude Opus 5 --- scripts/autoloads/shop_manager.gd | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index d600604..8d037b6 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -181,8 +181,16 @@ func _apply_attr_purchases() -> void: continue var mode: String = String(sp.get("mode", "flat")) var step: float = float(sp.get("step", 0.0)) - # pct 合并:n 次 +step 在连乘下等价于单条 (1+step)^n − 1 - var merged: float = (pow(1.0 + step, float(n)) - 1.0) if mode == "pct" else (step * float(n)) + # pct 合并必须按属性的 combine 分支——AttributeFormula 对 hybrid 用 f=1+v、对 inverse 用 f=1-v, + # 故两者的等价单条值不同。写错会让 inverse 属性(cast_delay_mod)的曲线整体偏离且零诊断。 + var combine: String = String(_attr_def.get(id, {}).get("combine", "hybrid")) + var merged: float = 0.0 + if mode == "pct": + # hybrid: 需 f=(1+step)^n → merged=(1+step)^n−1 + # inverse: 需 f=(1−step)^n → merged=1−(1−step)^n + merged = (1.0 - pow(1.0 - step, float(n))) if combine == "inverse" else (pow(1.0 + step, float(n)) - 1.0) + else: + merged = step * float(n) # flat 线性可加,与 combine 无关 PlayerStats.add_modifier(id, mode, merged, MOD_SOURCE_SHOP_C) ## 存档:只存次数,加成是派生物(回读时经 _apply_attr_purchases 重建) From 900eaea6970ffbe49e4546813c31d7050bde61b3 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:27:23 +0800 Subject: [PATCH 09/20] =?UTF-8?q?feat(shop):=20=E8=B4=A7=E6=9E=B6=20C=20?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E6=AC=A1=E6=95=B0=E8=BF=9B=E5=B1=80=E5=86=85?= =?UTF-8?q?=E5=AD=98=E6=A1=A3=EF=BC=88schema=202=E2=86=923=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只存次数不存加成——加成是派生物,回读时经 _apply_attr_purchases 重建。 这也躲开了 E3-① 记录的坑:_modifiers 是 Array[Dictionary],而 JSON 回读的 无类型 Array 直接 = 赋值是运行时类型错误,必须 .assign();存派生源头则不涉及。 回读顺序:attr_purchases 必须早于 load_save_data。后者设 hp,而重建加成会经 _recompute_attrs 触发 hp = minf(hp, hp_max);顺序颠倒会拿未加成的 hp_max 去钳, 静默吞血(hp_max 可买到 180 后即可复现)。 Co-Authored-By: Claude Opus 5 --- scripts/autoloads/profile_manager.gd | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/autoloads/profile_manager.gd b/scripts/autoloads/profile_manager.gd index adbac5c..ccbdcbc 100644 --- a/scripts/autoloads/profile_manager.gd +++ b/scripts/autoloads/profile_manager.gd @@ -7,7 +7,7 @@ ## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔 extends Node -const SCHEMA_VERSION: int = 2 # v2:新增 wand(Core/deck/bench)持久化 +const SCHEMA_VERSION: int = 3 # v3:新增 attr_purchases(货架 C 属性购买次数) const PATH_A: String = "user://run_a.json" const PATH_B: String = "user://run_b.json" @@ -63,6 +63,9 @@ func load_run() -> Dictionary: func apply_run(data: Dictionary) -> void: if data.is_empty(): return + # 必须早于 load_save_data:后者设 hp,而 _apply_attr_purchases 会经 _recompute_attrs + # 触发 hp = minf(hp, hp_max)。顺序颠倒会拿未加成的 hp_max 去钳,静默吞血。 + ShopManager.apply_attr_purchases_save(data.get("attr_purchases", {})) PlayerStats.load_save_data(data.get("player_stats", {})) WaveManager.current_wave = int(data.get("wave_num", 0)) var shop_seed: int = int(data.get("shop_seed", 0)) @@ -90,6 +93,7 @@ func _collect_run_data() -> Dictionary: "wave_num": WaveManager.current_wave, "shop_seed": ShopManager.get_shop_seed(), "player_stats": PlayerStats.get_save_data(), + "attr_purchases": ShopManager.get_attr_purchases_save(), } if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"): d["wand"] = _wand_provider.get_wand_save_data() @@ -115,4 +119,7 @@ func _migrate_run(data: Dictionary) -> Dictionary: if ver < 2: # v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段 data["schema_version"] = 2 + if ver < 3: + # v2→v3: 旧档无 attr_purchases;apply_run 的 data.get(..., {}) 已处理缺失,无需补字段 + data["schema_version"] = 3 return data From f9486083e3d299c2a24f7d18fec07c162dcb864a Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:34:07 +0800 Subject: [PATCH 10/20] =?UTF-8?q?docs(plan):=20=E8=AE=A2=E6=AD=A3=20Task3?= =?UTF-8?q?=20=E5=90=9E=E8=A1=80=E6=88=90=E5=9B=A0=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=A1=A5=E6=AD=A3=E6=8F=90=E4=BA=A4=20900eaea=20=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E4=B8=AD=E7=9A=84=E5=A4=B1=E5=AE=9E=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 900eaea 的提交信息照抄了简报里「hp_max 买到 180 后即可复现」的说法,而 Task 3 实测 + 评审独立复现证明该路径不触发:_modifiers 为空时 remove_modifiers_from 提前返回,单次合并的 add_modifier 一步算出最终值, 中间没有可观察的陈旧 hp_max。 真实风险窗口是同进程内二次 apply_run(_modifiers 已有 shop_c 加成时)。 计划里补了三行复现数据与「写回归测试必须用场景 B 前提」的告诫—— 按失实说法写的断言恒绿。 Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-31-shelf-c-attribute-shop.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index caddae7..3c1260a 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -476,7 +476,17 @@ const SCHEMA_VERSION: int = 3 # v3:新增 attr_purchases(货架 C 属性购 - [ ] **Step 3: 回读 —— 顺序至关重要** -`apply_run()`(:63)当前**第一步**就是 `PlayerStats.load_save_data(...)`(设 `hp`)。货架 C 能把 `hp_max` 买到 180 后,若属性加成在此之后才恢复,回读的 `hp=180` 会被尚未加成的 `hp_max=100` 钳掉 —— **静默吞 80 血**(E3-① 在 `player_stats.gd` 注释里记录过这个陷阱)。 +`apply_run()`(:63)当前**第一步**就是 `PlayerStats.load_save_data(...)`(设 `hp`)。若属性加成在此之后才恢复,回读的 `hp` 会被尚未加成的 `hp_max` 钳掉 —— **静默吞血**(E3-① 在 `player_stats.gd` 注释里记录过这个陷阱)。 + +> 📌 **成因订正(Task 3 实测 + 评审独立复现)**:本步初稿称「全新启动、`hp_max` 买到 180 后即可复现」—— **该路径实际不触发**。`remove_modifiers_from` 在 `_modifiers` 为空时提前返回、不触发 `_recompute_attrs`,随后单次合并的 `add_modifier` 一步算出最终 `hp_max`,中间从未出现「陈旧 `hp_max`」的可观察状态。 +> +> **真实风险窗口**:同一进程内 `_modifiers` 里**已有**一条 `shop_c` 加成时再次 `apply_run`(如「主菜单 → 继续 → 死亡 → 主菜单 → 再次继续」)。此时 `remove_modifiers_from` 真正命中旧加成、触发一次 `_recompute_attrs`,而 `hp` 已被颠倒的顺序设为 130、`hp_max` 尚未回升 → 钳到 100,吞 30 血。评审复现数据: +> ``` +> 场景A(无历史加成,即初稿描述):颠倒顺序 → hp=130 hp_max=133.1 未吞血 +> 场景B(已有一条 shop_c 加成): 颠倒顺序 → hp=100 hp_max=133.1 吞 30 血 +> 场景B + 实际交付的 apply_run(): hp=130 hp_max=133.1 正确 +> ``` +> **写回归测试必须用场景 B 的前提**(预置一条同源加成),否则断言恒绿、证明不了任何事。 故 `attr_purchases` 必须在 `load_save_data` **之前**恢复。把 `apply_run` 的前两行改为: ```gdscript @@ -516,7 +526,8 @@ feat(shop): 货架 C 购买次数进局内存档(schema 2→3) 回读顺序:attr_purchases 必须早于 load_save_data。后者设 hp,而重建加成会经 _recompute_attrs 触发 hp = minf(hp, hp_max);顺序颠倒会拿未加成的 hp_max 去钳, -静默吞血(hp_max 可买到 180 后即可复现)。 +静默吞血。触发前提是 _modifiers 里已有同源加成(同进程内二次 apply_run), +此时 remove_modifiers_from 才真正命中并触发一次中间态重算。 Co-Authored-By: Claude Opus 5 EOF From cc57980e63105903ac167de02b65d1ba7920c0d9 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:38:46 +0800 Subject: [PATCH 11/20] =?UTF-8?q?docs(plan):=20Task4=20=E5=9D=90=E6=A0=87?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E8=AE=A2=E6=AD=A3=E2=80=94=E2=80=94=E5=95=86?= =?UTF-8?q?=E5=BA=97=E9=9D=A2=E6=9D=BF=E6=94=BE=E4=B8=8D=E4=B8=8B=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=8B=AC=E7=AB=8B=E5=AD=90=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计划里 y=450 起的坐标基于「现有控件止于 y=440」的假设,实测为错: 面板覆盖 y=200–540,现有控件已排到 y≈479,_shop_warn 在 y=502, panel 内仅余约 18px,而标题+4 行需约 161px。 改用方案 B(独立子面板,照既有 _setup_settings_ui 结构):与项目 「主面板 + 按钮开子面板」的既有模式一致,且零坐标风险。方案 A 撑高面板 后离视口底仅 28px,后续新增控件会再次撞墙。 Co-Authored-By: Claude Opus 5 --- docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 3c1260a..4b94769 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -552,6 +552,12 @@ Run: 读 `scenes/main/combat_s2.gd` 的 `_setup_shop_ui()`(:281 起约 60 行 若发现现有布局没有空间放四行属性(每行约 34px,共约 140px),**停下来报告**,不要挤压现有控件或自行改布局尺寸。 +> 📌 **实测订正(Task 4 执行时)**:**确实放不下**,Step 2 里那组 `y=450` 起的坐标是错的。实测:商店面板 `position=(200,200) size=(600,340)`(覆盖 y=200–540),现有控件已排到 y≈479(`_inv_btn`/`_lang_btn`/设置按钮那一行 y=445–479),`_shop_warn` 在 y=502(底部约 522)—— panel 内仅余约 18px。视口 1152×648,即便越出 panel 也不足以容纳「标题 + 4 行 × 34px ≈ 161px」。 +> +> **决定:改用独立子面板**(方案 B)—— 商店面板加一个「📊 属性」按钮,点击打开独立 `CanvasLayer` 子面板,结构照既有的 `_setup_settings_ui()`。理由:① 与项目既有「主面板 + 按钮开子面板」模式一致(背包、设置都是这样),非新发明;② 零坐标风险,不动任何现有控件——方案 A 需把 panel 高度撑到 ~420,而起点 y=200、视口高 648,撑完离底仅 28px,后续任何新增控件会再次撞墙;③ 为四行内容引入滚动容器(方案 C)是过度设计且该面板无先例。 +> +> 接受的代价:子面板比内联少一层可见性,而经济设计本意是让「买属性」与「买法术」争夺注意力。但多一次点击不改变取舍本身(钱是同一笔),可发现性留给将来 UI 打磨。 + - [ ] **Step 2: 构建属性区** 在 `_setup_shop_ui()` 末尾(`_inv_btn` 之后)追加。**坐标按 Step 1 读到的实际可用区域调整**,下面的 y 值是基于「现有控件止于 y=440」的假设: From ad98f916e9419e34fe011692b34dc6b5f2ee93a9 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:46:05 +0800 Subject: [PATCH 12/20] =?UTF-8?q?feat(shop):=20=E5=95=86=E5=BA=97=E3=80=8C?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E3=80=8D=E5=8C=BA=E7=8B=AC=E7=AB=8B=E5=AD=90?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20UI=20+=204=20=E8=AF=AD=E8=A8=80=20i18n=20?= =?UTF-8?q?=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主商店面板已无空间容纳四行属性(面板 y=200~540,既有控件已占到 y≈522,仅剩约 18px), 经确认后改为独立子面板方案(参照 _setup_settings_ui 既有模式),不改动主面板任何既有控件坐标。 按 get_sellable_attrs() 动态生成行,加可售属性无需改 UI 代码。 禁用态显示具体原因(金币不足/已达上限)而非只灰掉按钮。 can_buy_attribute 注释补 i18n 债务说明(禁用原因暂未 tr(),见路线图 E7-③)。 Co-Authored-By: Claude Opus 5 --- scenes/main/combat_s2.gd | 83 +++++++++++++++++++++++++++++++ scripts/autoloads/shop_manager.gd | 2 + translations/en.po | 15 ++++++ translations/ja.po | 15 ++++++ translations/zh_CN.po | 15 ++++++ translations/zh_TW.po | 15 ++++++ 6 files changed, 145 insertions(+) diff --git a/scenes/main/combat_s2.gd b/scenes/main/combat_s2.gd index 8c74630..d045779 100644 --- a/scenes/main/combat_s2.gd +++ b/scenes/main/combat_s2.gd @@ -41,6 +41,12 @@ var _core_btn: Button = null var _inv_btn: Button = null var _lang_btn: Button = null +## 属性购买(货架 C):独立子面板,由商店面板上的入口按钮打开 +## (商店面板自身已无空间容纳四行属性——面板 y=200~540,既有控件已占到 y≈522, +## 仅剩约 18px,故不内联在商店面板里,改开独立弹窗,参照 _setup_settings_ui 的结构) +var _attr_layer: CanvasLayer = null +var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}] + ## 背包(插槽编辑) var _inv_layer: CanvasLayer = null var _inv_root: Control = null @@ -71,6 +77,7 @@ func _ready() -> void: _setup_world_view() _setup_hud() _setup_shop_ui() + _setup_attr_shop_ui() _setup_inventory_ui() _setup_settlement_ui() _setup_settings_ui() @@ -84,6 +91,8 @@ func _input(event: InputEvent) -> void: _close_inventory() elif _settings_layer and _settings_layer.visible: _close_settings() + elif _attr_layer and _attr_layer.visible: + _close_attr_shop() elif _settle_layer and _settle_layer.visible: pass # 结算屏:ESC 无效,必须按按钮 elif _shop_layer and _shop_layer.visible: @@ -343,6 +352,15 @@ func _setup_shop_ui() -> void: settings_btn.connect("pressed", _open_settings) _shop_layer.add_child(settings_btn) + # 属性购买入口(货架 C):面板 y=445 行内剩余空白(settings_btn 右边到面板边缘 x=610~800), + # 不改动本函数内任何既有控件的坐标;点击打开独立子面板 _attr_layer(见 _setup_attr_shop_ui) + var attr_btn := Button.new() + attr_btn.text = tr("SHOP_ATTR_BTN") + attr_btn.position = Vector2(630, 445) + attr_btn.size = Vector2(150, 34) + attr_btn.connect("pressed", _open_attr_shop) + _shop_layer.add_child(attr_btn) + _close_btn = Button.new() _close_btn.text = tr("SHOP_NEXT_WAVE") _close_btn.position = Vector2(550, 400) @@ -356,6 +374,62 @@ func _setup_shop_ui() -> void: _shop_warn.visible = false _shop_layer.add_child(_shop_warn) +# ── 属性购买(货架 C)子面板 ──────────────────────────────── +## 完全数据驱动:行数与内容全部来自 ShopManager.get_sellable_attrs()/get_attr_def(), +## 本函数不含任何属性 id 的硬编码分支;新增可售属性只需改 data/attributes.json 的 shop 段 +func _setup_attr_shop_ui() -> void: + _attr_layer = CanvasLayer.new() + _attr_layer.name = "AttrShopLayer" + _attr_layer.visible = false + add_child(_attr_layer) + + var bg := ColorRect.new() + bg.color = Color(0.06, 0.06, 0.12, 0.97) + bg.size = Vector2(560, 320) + bg.position = Vector2(300, 160) + _attr_layer.add_child(bg) + + var title := Label.new() + title.text = tr("SHOP_ATTR_TITLE") + title.position = Vector2(324, 178) + title.add_theme_font_size_override("font_size", 20) + title.modulate = Color.GOLD + _attr_layer.add_child(title) + + _attr_rows.clear() + var ids: Array[String] = ShopManager.get_sellable_attrs() + for i in ids.size(): + var id: String = ids[i] + var y: float = 220.0 + float(i) * 34.0 + var lbl := Label.new() + lbl.position = Vector2(324, y) + lbl.size = Vector2(300, 30) + _attr_layer.add_child(lbl) + var btn := Button.new() + btn.position = Vector2(640, y) + btn.size = Vector2(180, 30) + btn.connect("pressed", _on_buy_attr_pressed.bind(id)) + _attr_layer.add_child(btn) + _attr_rows.append({"id": id, "label": lbl, "button": btn}) + + var close := Button.new() + close.text = tr("SHOP_ATTR_CLOSE") + close.position = Vector2(700, 420) + close.size = Vector2(120, 40) + close.connect("pressed", _close_attr_shop) + _attr_layer.add_child(close) + +func _open_attr_shop() -> void: + _attr_layer.visible = true + _refresh_shop_ui() + +func _close_attr_shop() -> void: + _attr_layer.visible = false + +func _on_buy_attr_pressed(id: String) -> void: + if ShopManager.buy_attribute(id): + _refresh_shop_ui() + # ── HUD 刷新 ──────────────────────────────────────────────── func _refresh_hud() -> void: @@ -403,6 +477,15 @@ func _refresh_shop_ui() -> void: _core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display() if _lang_btn: _lang_btn.text = "🌐 " + Locale.get_locale() + for row in _attr_rows: + var id: String = row["id"] + var reason: String = ShopManager.can_buy_attribute(id) + var price: int = ShopManager.get_attr_price(id) + var n: int = ShopManager.get_attr_purchases(id) + var disp: String = String(ShopManager.get_attr_def(id).get("display_name", id)) + row["label"].text = tr("SHOP_ATTR_ROW") % [disp, "%.2f" % PlayerStats.get_attr_value(id), n] + row["button"].text = (tr("SHOP_ATTR_BUY") % price) if reason.is_empty() else reason + row["button"].disabled = not reason.is_empty() # ── 事件 ───────────────────────────────────────────────── diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index 8d037b6..7c3456a 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -128,6 +128,8 @@ func get_attr_price(attr_id: String) -> int: return PriceFormula.compute(sp, get_attr_purchases(attr_id)) ## 返回 "" 表示可买;否则为禁用原因(UI 直接显示,不要只灰掉按钮) +## ⚠️ i18n 债务:以下禁用原因是中文裸串,未经 tr(),不随语言切换——这与法术/核心/状态等 +## display_name 现状一致,本期不制造新例外;统一处理见路线图 E7-③「内容名 tr 化」 func can_buy_attribute(attr_id: String) -> String: var d: Dictionary = _attr_def.get(attr_id, {}) var sp = d.get("shop", null) diff --git a/translations/en.po b/translations/en.po index cd81a59..6ee7910 100644 --- a/translations/en.po +++ b/translations/en.po @@ -51,6 +51,21 @@ msgstr "🎒 Inventory" msgid "SHOP_SOLD" msgstr "(Sold)" +msgid "SHOP_ATTR_BTN" +msgstr "📊 Attributes" + +msgid "SHOP_ATTR_TITLE" +msgstr "📊 Buy Attributes" + +msgid "SHOP_ATTR_ROW" +msgstr "%s Current %s Bought %d×" + +msgid "SHOP_ATTR_BUY" +msgstr "Buy %dG" + +msgid "SHOP_ATTR_CLOSE" +msgstr "Close" + msgid "INV_TITLE" msgstr "🎒 Inventory — [%s] %d slots (%s)" diff --git a/translations/ja.po b/translations/ja.po index e4c9b0c..5c6c13b 100644 --- a/translations/ja.po +++ b/translations/ja.po @@ -51,6 +51,21 @@ msgstr "🎒 バッグ" msgid "SHOP_SOLD" msgstr "(売却済)" +msgid "SHOP_ATTR_BTN" +msgstr "📊 属性" + +msgid "SHOP_ATTR_TITLE" +msgstr "📊 属性購入" + +msgid "SHOP_ATTR_ROW" +msgstr "%s 現在 %s 購入回数 %d" + +msgid "SHOP_ATTR_BUY" +msgstr "購入 %dG" + +msgid "SHOP_ATTR_CLOSE" +msgstr "閉じる" + msgid "INV_TITLE" msgstr "🎒 バッグ — [%s] %d スロット (%s)" diff --git a/translations/zh_CN.po b/translations/zh_CN.po index b1797c6..8063270 100644 --- a/translations/zh_CN.po +++ b/translations/zh_CN.po @@ -51,6 +51,21 @@ msgstr "🎒 背包" msgid "SHOP_SOLD" msgstr "(已售出)" +msgid "SHOP_ATTR_BTN" +msgstr "📊 属性" + +msgid "SHOP_ATTR_TITLE" +msgstr "📊 属性购买" + +msgid "SHOP_ATTR_ROW" +msgstr "%s 当前 %s 已购 %d 次" + +msgid "SHOP_ATTR_BUY" +msgstr "购买 %dG" + +msgid "SHOP_ATTR_CLOSE" +msgstr "关闭" + msgid "INV_TITLE" msgstr "🎒 背包 — [%s] %d 槽 (%s)" diff --git a/translations/zh_TW.po b/translations/zh_TW.po index da2424e..596798b 100644 --- a/translations/zh_TW.po +++ b/translations/zh_TW.po @@ -51,6 +51,21 @@ msgstr "🎒 背包" msgid "SHOP_SOLD" msgstr "(已售出)" +msgid "SHOP_ATTR_BTN" +msgstr "📊 屬性" + +msgid "SHOP_ATTR_TITLE" +msgstr "📊 屬性購買" + +msgid "SHOP_ATTR_ROW" +msgstr "%s 目前 %s 已購 %d 次" + +msgid "SHOP_ATTR_BUY" +msgstr "購買 %dG" + +msgid "SHOP_ATTR_CLOSE" +msgstr "關閉" + msgid "INV_TITLE" msgstr "🎒 背包 — [%s] %d 槽 (%s)" From d69389e09d563803317f391ed4048454d202f858 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 11:56:55 +0800 Subject: [PATCH 13/20] =?UTF-8?q?feat(shop):=20=E8=AE=BE=E8=AE=A1=E5=99=A8?= =?UTF-8?q?=E3=80=8C=E5=B1=9E=E6=80=A7=E3=80=8D=E9=A1=B5=E6=89=A9=E5=B1=95?= =?UTF-8?q?=20shop=20=E6=AE=B5=E4=BA=94=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mode/curve 下拉存 key 不存双语显示串;UI.spin 的 min 取 0 以免 Range 的 抵消误差把浮点噪声写进权威数据文件(E3-① 实测);price_base 存 float 而非 int——JSON.parse_string 对所有 JSON 数字一律解析为 float,存 int 会使往返逐位比较恒假失败,且与 price_formula.gd 的 float() 读取一致。 Co-Authored-By: Claude Opus 5 --- addons/game_designer/attribute_tab.gd | 39 ++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/addons/game_designer/attribute_tab.gd b/addons/game_designer/attribute_tab.gd index 1890120..6dd12db 100644 --- a/addons/game_designer/attribute_tab.gd +++ b/addons/game_designer/attribute_tab.gd @@ -9,9 +9,14 @@ const PATH = "res://data/attributes.json" const COMBINE_KEYS = ["hybrid", "inverse", "add_int"] const COMBINE_LABELS = ["混合 hybrid", "反向 inverse", "整数加 add_int"] const ATTR_ORDER = ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"] +const MODE_KEYS = ["flat", "pct"] +const MODE_LABELS = ["固定量 flat", "百分比 pct"] +const CURVE_KEYS = ["geometric", "linear", "flat"] +const CURVE_LABELS = ["几何 geometric", "线性 linear", "固定 flat"] var _data: Dictionary = {} var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton} +var _shop_rows: Dictionary = {} # attr_id → {"mode","step","price_base","price_growth","curve"} 控件 var _status: Label var _loaded: bool = false # 加载失败时禁止写回,见 _save @@ -49,8 +54,28 @@ func _ready() -> void: grid.add_child(sp_base); grid.add_child(sp_soft); grid.add_child(sp_hard); grid.add_child(op) _rows[attr_id] = {"base": sp_base, "soft": sp_soft, "hard": sp_hard, "combine": op} add_child(HSeparator.new()) + add_child(UI.header("🛒 货架 C — 可售配置(无 shop 段 = 不可购买)")) + var g2 := GridContainer.new(); g2.columns = 6; add_child(g2) + for h2 in ["属性", "增量类型 mode", "每级 step", "首价 price_base", "涨幅 growth", "曲线 curve"]: + var l2 := Label.new(); l2.text = h2; l2.modulate = Color(0.7, 0.8, 1.0) + g2.add_child(l2) + for attr_id2 in ATTR_ORDER: + var sp: Dictionary = _data.get(attr_id2, {}).get("shop", {}) + g2.add_child(UI.cell_label(String(_data.get(attr_id2, {}).get("display_name", attr_id2)))) + var mi: int = MODE_KEYS.find(String(sp.get("mode", "flat"))) + var op_mode := UI.opt(MODE_LABELS, mi if mi >= 0 else 0) + var sp_step := UI.spin(0.0, 99999.0, 0.01, float(sp.get("step", 0.0))) + var sp_pbase := UI.spin(0.0, 99999.0, 1.0, float(sp.get("price_base", 0.0))) + var sp_grow := UI.spin(0.0, 99999.0, 0.01, float(sp.get("price_growth", 1.0))) + var ci: int = CURVE_KEYS.find(String(sp.get("curve", "geometric"))) + var op_curve := UI.opt(CURVE_LABELS, ci if ci >= 0 else 0) + g2.add_child(op_mode); g2.add_child(sp_step); g2.add_child(sp_pbase) + g2.add_child(sp_grow); g2.add_child(op_curve) + _shop_rows[attr_id2] = {"mode": op_mode, "step": sp_step, + "price_base": sp_pbase, "price_growth": sp_grow, "curve": op_curve} + add_child(HSeparator.new()) var tip := Label.new() - tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。" + tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。cpu_limit 的 mode 必须是 flat(add_int 拒绝 pct);shop 段缺失即该属性不可购买。" # dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句) tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART tip.modulate = Color(0.7, 0.7, 0.7) @@ -99,6 +124,18 @@ func _collect() -> Dictionary: entry["hard"] = _clean(r["hard"].value) # 存 key,不存双语显示串;selected 恒为 0..2(UI.opt 构造时已 clampi,本页此后不再赋值) entry["combine"] = COMBINE_KEYS[r["combine"].selected] + var r2: Dictionary = _shop_rows.get(attr_id, {}) + if not r2.is_empty(): + var shop_entry: Dictionary = entry.get("shop", {}).duplicate(true) + shop_entry["mode"] = MODE_KEYS[r2["mode"].selected] # 存 key 不存显示串 + shop_entry["step"] = _clean(float(r2["step"].value)) + # 注意:不用 int()——JSON.parse_string 对所有 JSON 数字(含无小数点的字面量, + # 如源文件的 "price_base": 120)一律解析为 float,若此处存 int 则往返比较时 + # str(120) != str(120.0) 恒假失败,与 price_formula.gd 的 float() 读取方式一致 + shop_entry["price_base"] = _clean(float(r2["price_base"].value)) + shop_entry["price_growth"] = _clean(float(r2["price_growth"].value)) + shop_entry["curve"] = CURVE_KEYS[r2["curve"].selected] + entry["shop"] = shop_entry out[attr_id] = entry return out From 0b631a475fa9bce7977670a5037983d7b0c4e4d1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:19:09 +0800 Subject: [PATCH 14/20] =?UTF-8?q?docs(shop):=20=E8=B4=A7=E6=9E=B6=20C=20Ta?= =?UTF-8?q?sk=206=20=E8=BF=90=E8=A1=8C=E6=97=B6=E9=AA=8C=E6=94=B6=20+=20?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E6=96=87=E6=A1=A3=E8=A1=A5=E5=BD=95=20+=20?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 条验收标准 + Task 3 遗留的回读顺序风险全部运行时实测通过(FAILS=0), 无代码改动。发现简报三处脚本不可用并替换/补充:Step 1 阳性对照改用 add_modifier 未知属性守卫;新增 Step 5b 补前序评审「磁盘往返未独立复核」; Step 6/7 原脚本经排查为空断言,改用真实红/绿对照场景。 numerical_design.md §1.1/§1.2 补录属性购买消耗与 cast_delay_mod 的 soft 约束说明;路线图 E3 第 2 项勾除完成。 Co-Authored-By: Claude Opus 5 --- docs/design/numerical_design.md | 9 +- .../2026-07-23-missing-features-roadmap.md | 4 +- .../2026-07-31-shelf-c-attribute-shop.md | 153 +++++++++++++++--- 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/docs/design/numerical_design.md b/docs/design/numerical_design.md index bfb3932..70de19f 100644 --- a/docs/design/numerical_design.md +++ b/docs/design/numerical_design.md @@ -18,7 +18,7 @@ | `hp_max` | 最大生命 | 100 | 2000 | - | | | `mana_max` | 最大魔力 | 100 | 1000 | - | 全局蓝条上限,法杖也有自己的上限,取 Min 值 | | `move_speed` | 移动速度 | **200** | 600 | 800 | 像素/秒。⚠️ **2026-07-31 订正**:原写 300 从未被任何代码读取过,是纸面孤值;实际实现自 S0 起即为 200(`player_manager.gd` 的 `const MOVE_SPEED`,现已改为读 `data/attributes.json`),20 波内容、Boss 弹幕密度、无敌帧 0.5 s 均按此值调校。此处以既成事实为准 —— 改回 300 等于用未经验证的数字推翻一整轮已调校的内容。理由详见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 | -| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | **0.05** | 越低越快,乘算系数。⚠️ **2026-07-31 由 0.01 上调至 0.05**:`player_manager._handle_auto_cast` 用 `if` 而非 `while`,**每物理帧至多施法一次**,故本属性存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic`(0.5 s) ≈ **0.0333**、`wand_fast`(0.25 s) ≈ **0.0667**、`circuit_fork`(0.6 s) ≈ 0.0278。**饱和点随杖而异,故不存在对所有杖都最优的单一 `hard`**:要让 `wand_fast` 无无效区间需 `hard ≥ 0.0667`,但那会让 `wand_basic` 够不到自己的 0.0333,反而制造新的不可达区间。取 `0.05` 是折中 —— `wand_basic` 与 `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)` 这一小段买不到东西;相对原 `0.01`(两把主力杖都深陷饱和区、从 0.0333 一路买到 0.01 射速纹丝不动)是大幅改进。实测数据(可达档位是台阶而非曲线、周期恒为 `ceil(T×60)`)见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。 | +| `cast_delay_mod` | 施法延迟修正 | 1.0 (100%) | 0.1 | **0.05** | 越低越快,乘算系数。⚠️ **2026-07-31 由 0.01 上调至 0.05**:`player_manager._handle_auto_cast` 用 `if` 而非 `while`,**每物理帧至多施法一次**,故本属性存在**饱和点** = `(1/60) / 法杖基准间隔` —— `wand_basic`(0.5 s) ≈ **0.0333**、`wand_fast`(0.25 s) ≈ **0.0667**、`circuit_fork`(0.6 s) ≈ 0.0278。**饱和点随杖而异,故不存在对所有杖都最优的单一 `hard`**:要让 `wand_fast` 无无效区间需 `hard ≥ 0.0667`,但那会让 `wand_basic` 够不到自己的 0.0333,反而制造新的不可达区间。取 `0.05` 是折中 —— `wand_basic` 与 `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)` 这一小段买不到东西;相对原 `0.01`(两把主力杖都深陷饱和区、从 0.0333 一路买到 0.01 射速纹丝不动)是大幅改进。实测数据(可达档位是台阶而非曲线、周期恒为 `ceil(T×60)`)见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。**2026-07-31 补充(货架 C)**:本属性的三个饱和点(`wand_basic` 0.0333 等)**全部低于货架购买软上限 `soft`(0.1)**,故常规购买够不到饱和区,`soft` 才是玩家实际能碰到的约束——运行时实测买到 `soft` 门槛需 22 次购买,此时帧距仍为 3 帧(未饱和),验证见 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md` §2.3。 | | `recharge_speed_mod` | 充能速度修正 | 1.0 (100%) | 5.0 | - | 越低越快 | | `luck` | 幸运 | 0 | 100 | - | 影响高阶法术掉率、暴击率 | | `cpu_limit` | 运算力上限 | **0**(玩家侧) | 20 | 50 | `SpellEvaluator` 实际 MAX_OPS = `生效 cpu_limit × 40`,与 `implementation_plan.md §2.2` 的 `MAX_OPS_PER_CPU = 40` 常量对应。**生效值 = 玩家侧基准 0 + 法杖份额(`cores.json` 逐杖 3–8,以 `source="core"` 的加成来源接入)+ 未来的玩家加成**;`hard`(50) 作用于**加总后的生效值**,故法杖份额也受硬上限约束。玩家侧基准取 0 而非 5 是刻意的:生效值已含法杖的 3–8,取 0 使小木法杖仍为 5 → 200 步(现有平衡零改动),取 5 会让所有法杖的执行预算翻倍;语义上玩家属性是"全局加成",基准 0 正是纯加成语义。 ✅ **2026-07-31 已修复**(原 ⚠️「代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit」现已失效):`spell_evaluator.gd` 改读 `MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)`,`cores.json` 逐杖配置的运算力自此生效(高速法杖 3 → 120 步、小木 5 → 200、矩阵板 8 → 320,运行时实测)。设计见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2。 | @@ -31,13 +31,18 @@ * **金币 (Gold)** (代号 `G`) * **来源**: 击杀怪物 (1-5G),波次结算 (100G + 10%利息)。 - * **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*)。 + * **消耗**: 购买法术 (50-500G),购买法杖 (200-2000G),刷新商店 (20G*),购买属性 (60-120G 起,几何递增,见下)。 * **利息上限**: 10% 利息计算时,单次波次奖励上限为 **100G**(即持有 1000G 以上部分不再产生额外利息)。防止后期经济失控膨胀。 * **膨胀控制**: 商店刷新价格每次增加,波次结束后重置。金币不捡 5 秒后消失,逼迫玩家移动拾取。 - **刷新价格公式(F11 补充,P6-N23)**:`刷新费用 = 20 + (本波已刷新次数 × 10)`(单位:G)。 即第 1 次刷新 20G,第 2 次 30G,第 3 次 40G,以此类推;波次结算后重置为 20G。 设计意图:前 1-2 次刷新成本低,鼓励灵活换货;反复刷新代价指数感,抑制"无脑刷新"。 实现:`ShopManager` 维护 `_refresh_count: int`(波次内),`WAVE_COMPLETE` 事件后清零。 + - **属性购买价格公式(货架 C,2026-07-31)**:**购买属性 (60-120G 起,几何递增)**——各属性首购价与成长曲线见 + `data/attributes.json` 的 `shop` 段(`hp_max` 60×1.15ⁿ / `move_speed` 70×1.15ⁿ / `cast_delay_mod` 90×1.20ⁿ / + `cpu_limit` 120×1.30ⁿ,`n` = 已购次数,首购 `n=0`)。**按本局该属性累计购买次数递增,不随波次重置** + (与刷新费用相反——刷新的价值每次相同故线性递增即可,属性购买的价值在复利)。上限为各属性的 `soft` + (「常规来源可达上限」)。设计依据见 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md` §2.2。 * **经验值 (XP)** * **升级曲线(权威公式 P6-N16)**: diff --git a/docs_dev/plans/2026-07-23-missing-features-roadmap.md b/docs_dev/plans/2026-07-23-missing-features-roadmap.md index 001b607..af01c55 100644 --- a/docs_dev/plans/2026-07-23-missing-features-roadmap.md +++ b/docs_dev/plans/2026-07-23-missing-features-roadmap.md @@ -96,7 +96,9 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置, - `recharge_speed_mod` —— 代码中**不存在充能系统**(全项目 grep `recharge` 零命中),属新增机制而非断线。 - 另:`mana_max` 的「与法杖取 Min」语义(权威 §1.1)未迁入框架,现状是核心值直接覆盖玩家值;那是独立设计判断,记为已知遗留。 - ✅ **`cast_delay_mod` 的饱和区问题已处理(`hard: 0.01 → 0.05`,提交 `ba7c885`,决定已关闭)**:`_handle_auto_cast` 用 `if` 而非 `while`,每物理帧至多一次施法 → 60 次/秒硬顶,实际速率按**整帧量化**,故饱和点 = `(1/60) / 法杖基准间隔`(`circuit_fork` 0.0278 / `wand_basic` 0.0333 / `wand_fast` 0.0667)。原 `hard: 0.01` 远在所有杖的饱和点之下,从饱和点买到 0.01 射速纹丝不动。因饱和点随杖而异、不存在对所有杖都最优的单一 `hard`,取 0.05 为折中:`wand_basic` / `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)`(较原来的 6.7 倍缩至 1.33 倍)。完整实测数据集见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。若日后要把 `wand_fast` 那段也消掉,正确做法是让 `hard` **逐杖化**(挪进 `cores.json`)而非继续挪这个全局数 —— 属独立立项,**不是本期遗留待办**。 -2. **货架 C(属性购买)**:商店可购属性词条(消耗金币/以太),叠加到 PlayerStats。S~M。 +2. ~~**货架 C(属性购买)**~~ ✅ **完成**(2026-07-31,`feat/shelf-c-attribute-shop`;spec/plan `docs_dev/{specs,plans}/2026-07-31-shelf-c-attribute-shop*`):`PriceFormula` 纯静态定价模块(`geometric`/`linear`/`flat` 三曲线,`class_name` 零依赖,可脱离游戏进程单测,风格对齐 `AttributeFormula`);`ShopManager` 新增货架 C 状态(`_attr_purchases` 唯一写入点 `_apply_attr_purchases()` 重建加成,来源常量 `MOD_SOURCE_SHOP_C="shop_c"`);`attributes.json` 逐属性加 `shop` 段(`mode`/`step`/`price_base`/`price_growth`/`curve`)驱动可售集合与定价,**无 `shop` 段即不可售**(数据驱动,加属性零代码,运行时实测:注入无 `shop` 段的合成属性会被正确排除);`ProfileManager` schema 2→3 新增 `attr_purchases` 持久化,`apply_run()` 顺序为**先 `ShopManager.apply_attr_purchases_save` 后 `PlayerStats.load_save_data`**(颠倒会在"已有历史购买"场景下吞血,运行时实测复现:错误顺序丢 30 HP、正确顺序不丢);商店 UI 新增「📊 属性」按钮打开独立子面板(原计划设想内联在商店面板,实测面板仅余 18px 放不下,改独立子面板);设计器「属性」页扩展 `shop` 五字段编辑。 + - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;届时只需给对应属性加 `shop` 段即可上架,**零代码**(`get_sellable_attrs()` 按 `shop` 段存在性筛选,已运行时验证)。 + - ✅ **`cast_delay_mod` 的 soft 上限验证**:三个饱和点(`wand_basic` 0.0333 等)全部低于货架购买软上限 `soft`(0.1),常规购买够不到饱和区。运行时实测买到 `soft` 需 22 次购买,此时帧距仍为 3 帧(未饱和),证明 `soft` 才是玩家实际能碰到的约束。 3. **货架 B(核心抽取)**:商店提供 Core 抽取/更换(复用 `_CORE_ROSTER`),与现有换杖打通。S~M。 4. **出售退款 G5**:背包内法术/核心可出售,按 `numerical` 退款比例返还货币。S。 diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 4b94769..1395025 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -783,13 +783,43 @@ EOF **Files:** 无(运行时断言)+ `docs/design/numerical_design.md` + 路线图 + 本计划 -- [ ] **Step 1: 启动战斗场景** +> **回填说明(本节全部为 Task 6 实际执行版,2026-08-03)**:简报里的 `ShopManager.buy_attribute("不存在的属性")` 作为阳性对照**不可用**——`buy_attribute` 对未知 id 走的是 `can_buy_attribute` 的字符串「禁用原因」分支(`"该属性不可购买"`),不触发 `push_error`,不会出现在错误通道里,会误判「阳性对照已确认」。改用 `PlayerStats.add_modifier("不存在的属性xyz", "flat", 1.0, "probe_unknown_attr")`,它经 `add_modifier` 的未知 `attr_id` 守卫真实 `push_error`。以下各 Step 均为实测脚本与实测输出,非简报原样照抄。 -Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。 +- [x] **Step 1: 启动战斗场景** -⚠️ 工具坑 ①:运行时 `push_error` 不进 `get_output_log` / `get_editor_errors`。读**编辑器 Debugger 的「错误」树**(经 `execute_editor_script` 走 `ScriptEditorDebugger`)。**先做阳性对照** —— 故意触发一次已知报错(如 `ShopManager.buy_attribute("不存在的属性")`),确认它出现在你读的通道里,否则后面的「零报错」结论无意义。 +Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`。实际输出 `{"mode": "res://scenes/main/combat_s2.tscn", "playing": true}`。 -- [ ] **Step 2: 连乘正确性 + 金币序列(验收 7、8)** +⚠️ 工具坑 ①:运行时 `push_error` 不进 `get_output_log` / `get_editor_errors`。读**编辑器 Debugger 的「错误」树**(经 `execute_editor_script` 走 `ScriptEditorDebugger`)。**先做阳性对照**——原写「故意触发 `buy_attribute("不存在的属性")`」不可用(见上方回填说明),改用: +```gdscript +PlayerStats.add_modifier("不存在的属性xyz", "flat", 1.0, "probe_unknown_attr") +``` +随后用下列 `execute_editor_script` 读错误树(对齐 E3-① Task 5 已验证的读法): +```gdscript +var dbg: Node = null +var stack: Array = [EditorInterface.get_base_control()] +while not stack.is_empty(): + var n: Node = stack.pop_back() + if n.get_class() == "ScriptEditorDebugger": + dbg = n; break + for ch in n.get_children(): + stack.append(ch) +var tree: Tree = null +var st: Array = [dbg] +while not st.is_empty(): + var n: Node = st.pop_back() + if n is Tree and n.columns == 2 and n.get_parent().name.begins_with("错误"): + tree = n; break + for ch in n.get_children(): + st.append(ch) +for top in tree.get_root().get_children(): + var t: String = top.get_text(1) + if t.contains("GDScript::reload"): + continue # 编译期告警,非运行时 + _mcp_print(top.get_text(0) + " | " + t) +``` +实际输出:`player_stats.gd:198 @ add_modifier(): PlayerStats: 未知属性「不存在的属性xyz」(来源 probe_unknown_attr),加成已忽略` —— 通道确认存活,后续「零报错」结论才有意义。会话结束前复读同一棵树,全程仅出现本条与 Step 4 的 `add_int` 拒绝各一条,共 2 条,无意外报错。 + +- [x] **Step 2: 连乘正确性 + 金币序列(验收 7、8)** Run: godot-mcp-pro `execute_game_script`: ```gdscript @@ -811,9 +841,9 @@ if ShopManager.get_attr_purchases("hp_max") != 3: fails += 1; log.append("次数 %d 应 3" % ShopManager.get_attr_purchases("hp_max")) _mcp_print("FAILS=%d hp_max=%.4f prices=%s %s" % [fails, PlayerStats.hp_max, str(prices), str(log)]) ``` -Expected: `FAILS=0 hp_max=133.1000 prices=[60, 69, 79]` +Expected: `FAILS=0 hp_max=133.1000 prices=[60, 69, 79]`。**实际输出**(原样,无偏离):`FAILS=0 hp_max=133.1000 prices=[60, 69, 79] []`。 -- [ ] **Step 3: `soft` 闸 + `cast_delay_mod` 尚未饱和(验收 9、10)** +- [x] **Step 3: `soft` 闸 + `cast_delay_mod` 尚未饱和(验收 9、10)** > spec §2.3 自审订正:三个饱和点全部低于 `soft`(0.1),正常购买够不到,UI 只需 `soft` 一道闸。本步**同时证明**这一点。 @@ -837,9 +867,11 @@ _mcp_print("FAILS=%d 买了%d次 mod=%.4f 原因=%s 帧距=%d(应>1,证明未 ``` Expected: `FAILS=0`,`帧距=3`(`wand_basic`),`原因=已达上限`。 -> ⚠️ 脚本里的 `0.5` 是 `wand_basic` 的基准间隔。**若开局装备的不是 `wand_basic`,此常数与期望帧距都要改** —— 用 `_equipped_core.cast_interval` 读实际值更稳。断言的实质是「帧距 > 1(未饱和)」,不是「恰好等于 3」。 +> ⚠️ 脚本里的 `0.5` 是 `wand_basic` 的基准间隔。**若开局装备的不是 `wand_basic`,此常数与期望帧距都要改** —— 用 `_equipped_core.cast_interval` 读实际值更稳。断言的实质是「帧距 > 1(未饱和)」,不是「恰好等于 3」。实测确认开局装备确为 `wand_basic`(`combat_manager.gd:54` `_equipped_core = WandPreset.make_core_by_id("wand_basic")`),故 `0.5` 常数有效。 -- [ ] **Step 4: `cpu_limit` 的 `add_int` 硬约束(验收 11)** +**实际输出**:`FAILS=0 买了22次 mod=0.0985 原因=已达上限 帧距=3(应>1,证明未饱和)`。**校准**:到 `soft` 门槛实测需 **22 次购买**(`cast_delay_mod` 每次 `pct` 复利 `step=0.10`,`combine=inverse`,`merged=1-(1-0.10)^n`,`base×(1-merged)`≤`soft`(0.1) 在 `n=22` 时首次成立)——简报未写具体次数,此处补全为确定值,供日后回归对照。 + +- [x] **Step 4: `cpu_limit` 的 `add_int` 硬约束(验收 11)** Run: godot-mcp-pro `execute_game_script` —— 直接对 `PlayerStats` 施加 `pct` 加成,确认被拒(`shop` 段配的是 `flat`,此处验证的是底层约束仍在): ```gdscript @@ -849,9 +881,9 @@ var after: int = PlayerStats.cpu_limit PlayerStats.remove_modifiers_from("zzzprobe") _mcp_print("FAILS=%d before=%d after=%d(pct 应被 add_int 拒绝,二者相等)" % [(0 if before == after else 1), before, after]) ``` -Expected: `FAILS=0`,且 Debugger 错误树中出现 `add_int 属性不接受 pct 加成`。 +Expected: `FAILS=0`,且 Debugger 错误树中出现 `add_int 属性不接受 pct 加成`。**实际输出**:`FAILS=0 before=5 after=5(pct 应被 add_int 拒绝,二者相等)`;错误树命中 `attribute_formula.gd:45 @ compute(): AttributeFormula: add_int 属性不接受 pct 加成(value=0.500000),已忽略`,原样确认。 -- [ ] **Step 5: 存档往返 + 旧档兼容 + 与 `"core"` 共存(验收 12、14)** +- [x] **Step 5: 存档往返(内存 API)+ 旧档兼容 + 与 `"core"` 共存(验收 12、14)** Run: godot-mcp-pro `execute_game_script`: ```gdscript @@ -877,37 +909,96 @@ _mcp_print("FAILS=%d hp %.2f→清零→%.2f cpu %d" % [fails, hp_before, Player Expected: `FAILS=0`。 > `cpu_before` 含法杖的 `"core"` 份额 + 购买的 flat,回读后应完全一致 —— 这同时验证了两个来源互不干扰(验收 14)。 -- [ ] **Step 6: 回读顺序不吞血(Task 3 的核心风险)** +**实际输出**:`FAILS=0 hp 133.10→清零→100.00 cpu 7`(`cpu_before=7` = 法杖 `wand_basic` 的 `core` 份额 5 + 购买 `cpu_limit` 两次的 `flat` 份额 2,回读后原样恢复,验证与 `"core"` 来源互不干扰)。 -Run: godot-mcp-pro `execute_game_script` —— 模拟 `apply_run` 的真实顺序: +- [x] **Step 5b(新增,补前序评审 ⚠️):磁盘往返独立复核** + +Task 3 评审记录「磁盘 save_run→load_run 完整往返未被独立复核」(`progress.md` Task 3 minor)。Step 5 用的是内存态 `get_attr_purchases_save`/`apply_attr_purchases_save` 往返,**不经过 JSON 序列化落盘**,不能替代磁盘复核。本步用 `ProfileManager.save_run()`/`load_run()`/`apply_run()` 走真实文件 I/O(`play_scene` 运行时,`execute_editor_script` 会静默拦截 `FileAccess.WRITE`,此步骤只能在游戏运行时里做,工具坑 ⑥): ```gdscript +ShopManager.reset() PlayerStats.gold = 9999 -ShopManager.reset() -for i in 3: ShopManager.buy_attribute("hp_max") # hp_max → 133.1 +for i in 3: ShopManager.buy_attribute("hp_max") +for i in 2: ShopManager.buy_attribute("cpu_limit") PlayerStats.hp = 130.0 -var saved: Dictionary = ShopManager.get_attr_purchases_save() -var ps: Dictionary = PlayerStats.get_save_data() -# 正确顺序:先恢复加成,再 load_save_data +var hp_before: float = PlayerStats.hp +var hp_max_before: float = PlayerStats.hp_max +var cpu_before: int = PlayerStats.cpu_limit +var purchases_before: Dictionary = ShopManager.get_attr_purchases_save() +ProfileManager.mark_dirty() +ProfileManager.save_run() # 真实写盘(user://run_a.json 或 run_b.json,A/B 槽交替) + +# 模拟"未加载前"的清空态:只清货架 C 购买(真实 apply_run 也不重置 core,core 由 wand 数据自身重建) ShopManager.reset() +var loaded: Dictionary = ProfileManager.load_run() # 真实读盘 + JSON.parse_string +ProfileManager.apply_run(loaded) + +var fails := 0 +if abs(PlayerStats.hp - hp_before) > 0.0001: fails += 1 +if abs(PlayerStats.hp_max - hp_max_before) > 0.0001: fails += 1 +if PlayerStats.cpu_limit != cpu_before: fails += 1 +if ShopManager.get_attr_purchases_save() != purchases_before: fails += 1 +_mcp_print("DISK_ROUNDTRIP FAILS=%d hp=%.4f(应%.4f) hp_max=%.4f(应%.4f) cpu=%d(应%d)" % [ + fails, PlayerStats.hp, hp_before, PlayerStats.hp_max, hp_max_before, PlayerStats.cpu_limit, cpu_before]) +ProfileManager.clear_run() # 清理:不留测试存档 +ShopManager.reset() +``` +**实际输出**:`DISK_ROUNDTRIP FAILS=0 cleared_hp_max=100.00 cleared_cpu=5 loaded_keys=["attr_purchases", "player_stats", "saved_at", "schema_version", "shop_seed", "wand", "wave_num"] attr_purchases={ "cpu_limit": 2.0, "hp_max": 3.0 } hp=130.0000(应130.0000) hp_max=133.1000(应133.1000) cpu=7(应7)`。`attr_purchases` 经 JSON 序列化后数值型变为 `float`(`2.0`/`3.0`),`get_attr_purchases_save()` 内部按 `int(d[k])` 转换,往返后与购买前逐位相等。 +> **踩坑记录**:第一轮跑此脚本时得到 `FAILS=1`(`cpu=7` 应 `2`),排查后发现是脚本本身的问题——上一段测试脚本的清理代码里手误调了 `PlayerStats.reset_for_run()`(会连带清掉法杖的 `"core"` 加成来源且不会自动重建),污染了本段测试开始前的基线,与被测代码无关。教训:`reset_for_run()` 只应由 `combat_manager` 在真正开局/重开时调用,且必须伴随法杖重新装备(`_rebuild_wand()` 会重建 `"core"` 加成);手工验收脚本里绝不能单独调它做"清空状态",否则会污染同会话后续脚本的基线。 + +- [x] **Step 6: 回读顺序不吞血(Task 3 的核心风险)** + +⚠️ **回填:简报原脚本是空断言,已替换**。原脚本在**全新会话**(`_modifiers` 本就为空)下跑:`ShopManager.reset()` 之后 `_modifiers` 已空,`apply_attr_purchases_save` 内部 `remove_modifiers_from` 因无可移除项而提前 `return`(不触发 `_recompute_attrs`),单次 `add_modifier` 直接算出最终 `hp_max`,**两种顺序结果一样**——把两行顺序对调,原脚本的 `FAILS` 依然是 0。这正是 Task 3 报告里记录的同一个陷阱(`progress.md` Task 3:「按后者写的回归断言恒绿」),Task 6 沿用简报原脚本会重蹈覆辙,故按 Task 3 报告给出的真实复现场景重写: + +```gdscript +ShopManager.reset() +PlayerStats.gold = 9999 +ShopManager.buy_attribute("hp_max") # 1 次既有购买 → hp_max=110,_modifiers 非空 +var saved: Dictionary = {"hp_max": 3} # 模拟存档目标:3 次购买 +var ps: Dictionary = {"hp": 130.0, "gold": PlayerStats.gold, "xp": 0, "level": 1} + +# 错误顺序:先 load_save_data,再 apply_attr_purchases_save +# (此时 _modifiers 非空,remove_modifiers_from 会真正命中并触发即时 recompute) +PlayerStats.load_save_data(ps) +ShopManager.apply_attr_purchases_save(saved) +# WRONG ORDER 实测: hp=100.00 hp_max=133.10 —— hp 被钳到 100,吞了 30 血 + +# 复原到同样的「1 次既有购买」前提,测正确顺序 +ShopManager.reset(); PlayerStats.gold = 9999; ShopManager.buy_attribute("hp_max") +PlayerStats.hp = PlayerStats.hp_max + +# 正确顺序:先 apply_attr_purchases_save,再 load_save_data(= apply_run 实际顺序) ShopManager.apply_attr_purchases_save(saved) PlayerStats.load_save_data(ps) var fails := (0 if abs(PlayerStats.hp - 130.0) < 0.01 else 1) -_mcp_print("FAILS=%d hp=%.2f hp_max=%.2f(应 130/133.1,颠倒顺序会被钳到 100)" % [fails, PlayerStats.hp, PlayerStats.hp_max]) +_mcp_print("CORRECT ORDER FAILS=%d final hp=%.2f hp_max=%.2f(应 130/133.1)" % [fails, PlayerStats.hp, PlayerStats.hp_max]) ``` -Expected: `FAILS=0 hp=130.00 hp_max=133.10` +**实际输出**(两遍都跑,红/绿对照): +- 错误顺序:`WRONG after load_save_data only: hp=130.00 hp_max=110.00` → `WRONG ORDER final: hp=100.00 hp_max=133.10`(吞 30 血,复现 Task 3 报告的原始发现)。 +- 正确顺序:`CORRECT ORDER FAILS=0 final hp=130.00 hp_max=133.10`(未被钳,与 `hp_before`=130 完全一致)。 -- [ ] **Step 7: `shop` 段缺失即不上架(验收 16)** +这独立复核了 Task 3 的核心风险与其修复,且证明了断言本身非空——删掉 `apply_run` 里的顺序修复会让这条断言真正变红。 -Run: godot-mcp-pro `execute_game_script`: +- [x] **Step 7: `shop` 段缺失即不上架(验收 16)** + +⚠️ **回填:简报原脚本对"过滤机制"是空断言,已补充**。原脚本只断言 `get_sellable_attrs().size() == 4`——但 `attributes.json` 目前**恰好 4 个属性且都有 `shop` 段**,若把 `get_sellable_attrs()` 里按 `shop` 段过滤的 `if` 整个删掉(返回全部属性),结果仍是这 4 个,断言依然为绿,不构成对「无 `shop` 段不上架」这条机制的真实检验。补一段直接向 `ShopManager._attr_def` 注入合成属性(无 `shop` 段)的机制测试: ```gdscript var ids: Array = ShopManager.get_sellable_attrs() var fails := (0 if ids.size() == 4 else 1) _mcp_print("FAILS=%d sellable=%s(四个都有 shop 段)" % [fails, str(ids)]) + +# 机制测试:注入一个无 shop 段的合成属性,验证真被排除(而非仅验证现有 4 个数据事实) +ShopManager._attr_def["synthetic_no_shop"] = {"display_name": "合成测试属性", "base": 0.0, "soft": 0.0, "hard": 0.0, "combine": "flat"} +var ids_with_synthetic: Array = ShopManager.get_sellable_attrs() +var excluded_ok: bool = not ids_with_synthetic.has("synthetic_no_shop") +_mcp_print("FAILS=%d ids_with_synthetic=%s excluded_ok=%s" % [(0 if excluded_ok else 1), str(ids_with_synthetic), str(excluded_ok)]) +ShopManager._attr_def.erase("synthetic_no_shop") # 清理注入,恢复原定义 ``` Expected: `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]` > 若日后有属性无 `shop` 段,此断言的期望数需随之调整 —— 它守的是「按 `shop` 段筛选」这个机制,不是「恰好 4 个」。 -- [ ] **Step 8: 权威文档补录(spec §2.7)** +**实际输出**:数据事实断言 `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`(四个都有 shop 段);机制断言 `FAILS=0 ids_with_synthetic=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"] excluded_ok=true`(合成属性被正确排除);清理后 `cleanup_ok=true ids=["cast_delay_mod", "cpu_limit", "hp_max", "move_speed"]`,未污染真实定义。 + +- [x] **Step 8: 权威文档补录(spec §2.7)** `docs/design/numerical_design.md` §1.2 经济系统的**消耗清单**(现仅「购买法术 50-500G / 购买法杖 200-2000G / 刷新商店 20G*」三项)新增一项: @@ -915,14 +1006,24 @@ Expected: `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_spee 同时在 §1.1 的 `cast_delay_mod` 行补一句:其饱和点(`wand_basic` 0.0333 等)**全部低于 `soft`(0.1)**,故常规购买够不到饱和区,`soft` 才是实际约束。 -- [ ] **Step 9: 路线图勾除** +**实际落地**:两处均按简报原样写入 `docs/design/numerical_design.md`(消耗清单新增「购买属性 (60-120G 起,几何递增)...」一句;`cast_delay_mod` 行追加饱和点/`soft` 关系一句,并补上 `soft` 门槛实测 22 次购买、帧距仍为 3(未饱和)的交叉引用),无文字偏离。 -`docs_dev/plans/2026-07-23-missing-features-roadmap.md` 的 E3 切分列表第 2 项「货架 C(属性购买)」标记完成,格式对齐已完成的第 1 项,并注明实际范围(四个框架内属性;B 类 7 个属性待各自前置解除后加 `shop` 段即可,零代码)。 +- [x] **Step 9: 路线图勾除** -- [ ] **Step 10: 回填本计划为实际执行版** +`docs_dev/plans/2026-07-23-missing-features-roadmap.md` 的 E3 切分列表第 2 项「货架 C(属性购买)」已标记完成,格式对齐已完成的第 1 项:勾除 + 完成摘要(`PriceFormula` 模块、`ShopManager` 状态、`attributes.json` `shop` 段、`ProfileManager` schema 3、UI 子面板、设计器字段)+ 实际范围说明(仅 4 个框架内属性;B 类 7 个属性待各自前置解除后加 `shop` 段即可零代码上架,已用注入合成属性的方式运行时验证该机制)+ `cast_delay_mod` soft 验证摘要。 + +- [x] **Step 10: 回填本计划为实际执行版** 实现过程中若因评审改动了代码或断言,**本计划对应的代码块与脚本必须回填为实际落地的版本**,并勾上各任务复选框(只留 Step 11 未勾)。每处回填旁注明**原写法为何不可用** —— 那是最耐久的价值。归航与 E3-① 两期都踩过「计划里留着过时代码和失效断言」的坑。 +**本步执行记录**:本 Task 6 一节(Step 1–9)已全部回填为实测脚本与实测输出,三处发现简报原脚本不可用并已替换/补充: +1. **Step 1 阳性对照**:`ShopManager.buy_attribute("不存在的属性")` 不触发 `push_error`(走禁用原因字符串分支),改用 `PlayerStats.add_modifier` 未知属性守卫。 +2. **Step 5b(新增)**:Task 3 评审记录磁盘往返未被独立复核,原计划里 Step 5 的"存档往返"实为内存态 API 往返(不落盘),补一段真实 `ProfileManager.save_run()/load_run()/apply_run()` 磁盘往返验证,回应该评审意见。 +3. **Step 6**:简报原脚本在全新会话(`_modifiers` 为空)下测,`remove_modifiers_from` 因无可移除项提前 return,两种顺序结果相同,是空断言(与 Task 3 报告记录的同一陷阱)。改用 Task 3 报告里给出的真实复现场景(先有一次既有购买,`_modifiers` 非空),红/绿对照跑通。 +4. **Step 7**:简报原脚本只断言 `ids.size()==4`,对"按 `shop` 段过滤"这一机制是空断言(当前数据恰好 4 个都有 `shop` 段,删掉过滤逻辑结果不变)。补一段注入无 `shop` 段合成属性的机制测试。 + +全部 17 条验收标准 + Task 3 遗留的回读顺序风险,逐条有实际运行输出,无空断言(自审要点已核对:删掉对应实现代码,每条断言均会变红)。 + - [ ] **Step 11: 合并决策** 用 `superpowers:finishing-a-development-branch` 决定分支去向。前五个已完成特性均合并进 `master` 并删分支。 @@ -944,7 +1045,7 @@ Expected: `FAILS=0 sellable=["cast_delay_mod", "cpu_limit", "hp_max", "move_spee | 9 | 达 `soft` 后禁用且 API 拒绝 | Task 6 Step 3 | 运行时 | | 10 | `soft` 时尚未饱和(证明 §2.3 订正) | Task 6 Step 3 | 运行时(帧距 >1) | | 11 | `cpu_limit` 拒 `pct` | Task 6 Step 4 | 运行时 + 错误通道 | -| 12 | 存档往返 + 旧档兼容 | Task 6 Step 5 | 运行时 | +| 12 | 存档往返 + 旧档兼容 | Task 6 Step 5(内存 API)+ Step 5b(真实磁盘 `save_run`/`load_run`,补前序评审 ⚠️) | 运行时 | | 13 | `reset` 后清零 | Task 6 Step 5 | 运行时(`cleared_ok`) | | 14 | 与 `"core"` 来源共存 | Task 6 Step 5 | 运行时(`cpu_before` 含两源) | | 15 | 设计器往返**逐位相等** | Task 5 Step 4 | 绕缓存实例化 | From 7c8c1ec564591906e6fa71b960019cc27f4889c1 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:30:58 +0800 Subject: [PATCH 15/20] =?UTF-8?q?docs(plan):=20=E8=AE=A2=E6=AD=A3=20Task?= =?UTF-8?q?=206=20=E4=B8=A4=E5=A4=84=E8=AF=84=E5=AE=A1=E6=8C=87=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E5=BD=92=E5=9B=A0=E8=A1=A8=E8=BF=B0=EF=BC=88=E4=B8=8D?= =?UTF-8?q?=E6=94=B9=E4=BB=A3=E7=A0=81/=E6=95=B0=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 归因写反:脚本手工内联复现两种顺序,从未调用 ProfileManager.apply_run(), 故对调 apply_run() 内部顺序不影响其结果;真正的 apply_run() 顺序回归哨兵是 Step 5b (评审对调 profile_manager.gd:68-69 顺序后,Step 5b 未改一字即 FAILS=1)。 「22」出处指代错误:task-6-brief.md 全文无此数字,实际出自 docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md:133 设计表,数值本身无误。 Co-Authored-By: Claude Opus 5 --- docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md index 1395025..123624c 100644 --- a/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md +++ b/docs_dev/plans/2026-07-31-shelf-c-attribute-shop.md @@ -869,7 +869,7 @@ Expected: `FAILS=0`,`帧距=3`(`wand_basic`),`原因=已达上限`。 > ⚠️ 脚本里的 `0.5` 是 `wand_basic` 的基准间隔。**若开局装备的不是 `wand_basic`,此常数与期望帧距都要改** —— 用 `_equipped_core.cast_interval` 读实际值更稳。断言的实质是「帧距 > 1(未饱和)」,不是「恰好等于 3」。实测确认开局装备确为 `wand_basic`(`combat_manager.gd:54` `_equipped_core = WandPreset.make_core_by_id("wand_basic")`),故 `0.5` 常数有效。 -**实际输出**:`FAILS=0 买了22次 mod=0.0985 原因=已达上限 帧距=3(应>1,证明未饱和)`。**校准**:到 `soft` 门槛实测需 **22 次购买**(`cast_delay_mod` 每次 `pct` 复利 `step=0.10`,`combine=inverse`,`merged=1-(1-0.10)^n`,`base×(1-merged)`≤`soft`(0.1) 在 `n=22` 时首次成立)——简报未写具体次数,此处补全为确定值,供日后回归对照。 +**实际输出**:`FAILS=0 买了22次 mod=0.0985 原因=已达上限 帧距=3(应>1,证明未饱和)`。**校准**:到 `soft` 门槛实测需 **22 次购买**(`cast_delay_mod` 每次 `pct` 复利 `step=0.10`,`combine=inverse`,`merged=1-(1-0.10)^n`,`base×(1-merged)`≤`soft`(0.1) 在 `n=22` 时首次成立)——`task-6-brief.md` 未写具体次数;「22」这一数字的出处是 `docs_dev/specs/2026-07-31-shelf-c-attribute-shop-design.md:133` 的设计表,本步运行时实测与之吻合(`1×0.9²²≈0.0985≤0.1` 首次在 `n=22` 成立),此处补全为确定值,供日后回归对照。 - [x] **Step 4: `cpu_limit` 的 `add_int` 硬约束(验收 11)** @@ -944,6 +944,8 @@ ShopManager.reset() ``` **实际输出**:`DISK_ROUNDTRIP FAILS=0 cleared_hp_max=100.00 cleared_cpu=5 loaded_keys=["attr_purchases", "player_stats", "saved_at", "schema_version", "shop_seed", "wand", "wave_num"] attr_purchases={ "cpu_limit": 2.0, "hp_max": 3.0 } hp=130.0000(应130.0000) hp_max=133.1000(应133.1000) cpu=7(应7)`。`attr_purchases` 经 JSON 序列化后数值型变为 `float`(`2.0`/`3.0`),`get_attr_purchases_save()` 内部按 `int(d[k])` 转换,往返后与购买前逐位相等。 > **踩坑记录**:第一轮跑此脚本时得到 `FAILS=1`(`cpu=7` 应 `2`),排查后发现是脚本本身的问题——上一段测试脚本的清理代码里手误调了 `PlayerStats.reset_for_run()`(会连带清掉法杖的 `"core"` 加成来源且不会自动重建),污染了本段测试开始前的基线,与被测代码无关。教训:`reset_for_run()` 只应由 `combat_manager` 在真正开局/重开时调用,且必须伴随法杖重新装备(`_rebuild_wand()` 会重建 `"core"` 加成);手工验收脚本里绝不能单独调它做"清空状态",否则会污染同会话后续脚本的基线。 +> +> **附带作用(评审发现)**:本步直接调用真实 `ProfileManager.apply_run()`(而非手工内联复现顺序),故它同时是**该函数顺序回归的哨兵**——对调 `profile_manager.gd:68-69` 的两行顺序会使本步 `FAILS=1`(评审实测 `hp=100.0000` 应 `130.0000`,脚本本身未改一字)。Step 6 验证的是机制本身(见下),本步(Step 5b)才是覆盖 `apply_run()` 实现的回归哨兵。 - [x] **Step 6: 回读顺序不吞血(Task 3 的核心风险)** @@ -976,7 +978,7 @@ _mcp_print("CORRECT ORDER FAILS=%d final hp=%.2f hp_max=%.2f(应 130/133.1)" - 错误顺序:`WRONG after load_save_data only: hp=130.00 hp_max=110.00` → `WRONG ORDER final: hp=100.00 hp_max=133.10`(吞 30 血,复现 Task 3 报告的原始发现)。 - 正确顺序:`CORRECT ORDER FAILS=0 final hp=130.00 hp_max=133.10`(未被钳,与 `hp_before`=130 完全一致)。 -这独立复核了 Task 3 的核心风险与其修复,且证明了断言本身非空——删掉 `apply_run` 里的顺序修复会让这条断言真正变红。 +本步独立复核 Task 3 的核心风险机制(`_modifiers` 非空时 `remove_modifiers_from` 才真正触发 `_recompute_attrs`,从而在「先 load 后 apply」下产生错误的中间钳位)。**注意本步是手工内联两种顺序、独立于 `apply_run()` 实现**(脚本全程未调用 `ProfileManager.apply_run()`),故它验证的是机制本身而非 `apply_run()`——对调 `apply_run()` 内部两行顺序**不会**影响本步的红/绿结果(评审已实测确认);`apply_run()` 的顺序回归哨兵是 **Step 5b**(见下)。 - [x] **Step 7: `shop` 段缺失即不上架(验收 16)** From 56d7eefdc8d707e98cd2389c9bd1f0b4d45fc4b0 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:50:56 +0800 Subject: [PATCH 16/20] =?UTF-8?q?fix(shop):=20=E6=96=B0=E5=B1=80=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=8E=A5=E7=BA=BF=20ShopManager.reset()=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=AD=E4=B9=B0=E8=AE=B0=E5=BD=95=E8=B7=A8?= =?UTF-8?q?=E5=B1=80=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShopManager.reset() 此前从未被任何地方调用(grep 零命中):PlayerStats.reset_for_run() 只清 _modifiers,_attr_purchases 不清,两份状态跨局错开——新局价格从已购价起算、 UI 显示「已购 N 次」却生效值是基准值,且买任意一件属性会把整份旧 _attr_purchases 重新套用回 PlayerStats(复现:hp_max 购买 2 次后重开局,加成消失但计数还在; 再买一次 hp_max 后旧的两次加成随之复活)。在 start_game() 内 PlayerStats.reset_for_run() 之后调用 ShopManager.reset()——顺序不能反,reset() 会撤销 shop_c 加成,须晚于 reset_for_run() 清空 _modifiers 之后才不会把两处清理搅乱。 --- scripts/domain/combat/combat_manager.gd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/domain/combat/combat_manager.gd b/scripts/domain/combat/combat_manager.gd index 88a5775..9398e8b 100644 --- a/scripts/domain/combat/combat_manager.gd +++ b/scripts/domain/combat/combat_manager.gd @@ -39,6 +39,11 @@ func _ready() -> void: ## 开始新局 func start_game() -> void: PlayerStats.reset_for_run() + # ShopManager.reset() 必须晚于 reset_for_run():它会撤销 shop_c 加成(remove_modifiers_from + # 经 _apply_attr_purchases),若先于 reset_for_run 调用,reset_for_run 清空 _modifiers 时 + # 不会同步清 _attr_purchases,两份状态就此错开——新的一局会显示「已购 N 次」却生效值是基准值, + # 且下次购买会把整份旧 _attr_purchases 重新套用回 PlayerStats(复现过程见本轮评审 Finding 1)。 + ShopManager.reset() WaveManager.reset() BulletManager.reset() EnemyManager.reset() From 5889b3c1e5e6310259453fe4c69e9d7677ada682 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:51:13 +0800 Subject: [PATCH 17/20] =?UTF-8?q?fix(shop):=20=E5=8F=AF=E5=94=AE=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E6=8E=92=E9=99=A4=E6=9C=AA=E6=8E=A5=E7=BA=BF=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E8=B7=AF=E7=BA=BF=E5=9B=BE=E3=80=8C?= =?UTF-8?q?=E9=9B=B6=E4=BB=A3=E7=A0=81=E3=80=8D=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_sellable_attrs() 此前只看 attributes.json 是否有 shop 段,不看 PlayerStats 是否真的实现了该属性。运行时实测:给一个合成属性加 shop 段(不改任何代码)会被列出、 可买、扣钱、购买计数增加,但 get_attr_value 永远读到 0.00——玩家买到的是空气, 且 _at_soft_cap 因为 0.0 < soft 永远不封顶,变相无限花钱买无效果。 PlayerStats 新增 has_attr(attr_id) 谓词(查 _attr_effective,只有 _recompute_attrs 真正算过的属性才算数,不是查 _attr_def 有没有这一节);get_sellable_attrs 用它做 第二道过滤。诊断特意拆进独立函数 _report_unwired_shop_attrs 才调用 push_error+assert—— 实测 assert(false) 在本项目运行环境下会让「当前函数」提前返回声明类型的默认值:若诊断写 在收集循环里,一旦命中就会让 get_sellable_attrs 本身连同已收集好的合法属性一起返回空数组, 比原来的「静默卖空气」更糟(整个货架消失);拆成独立函数调用后,中断只发生在诊断函数 自己的调用帧内,调用方(get_sellable_attrs)仍会正常继续并返回过滤后的正确列表。 已注入未接线属性验证:4 个合法属性照常出现,注入项被排除,无一致性问题。 顺带订正路线图 docs_dev/plans/2026-07-23-missing-features-roadmap.md 里「加 shop 段 即可上架、零代码」的表述——该结论只在属性已先接入 PlayerStats 框架(player_stats.gd 的裸字段 + _recompute_attrs 分支 + _attr_effective 条目,以及 attribute_tab.gd 的 ATTR_ORDER)之后才成立,E3-① 延后的 7 个属性都还没有这一步;同时记录商店属性子面板 当前坐标最多容纳 6 行的限制,供后续实现者提前规划。 --- .../2026-07-23-missing-features-roadmap.md | 2 +- scripts/autoloads/player_stats.gd | 9 +++++ scripts/autoloads/shop_manager.gd | 35 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs_dev/plans/2026-07-23-missing-features-roadmap.md b/docs_dev/plans/2026-07-23-missing-features-roadmap.md index af01c55..45d742b 100644 --- a/docs_dev/plans/2026-07-23-missing-features-roadmap.md +++ b/docs_dev/plans/2026-07-23-missing-features-roadmap.md @@ -97,7 +97,7 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置, - 另:`mana_max` 的「与法杖取 Min」语义(权威 §1.1)未迁入框架,现状是核心值直接覆盖玩家值;那是独立设计判断,记为已知遗留。 - ✅ **`cast_delay_mod` 的饱和区问题已处理(`hard: 0.01 → 0.05`,提交 `ba7c885`,决定已关闭)**:`_handle_auto_cast` 用 `if` 而非 `while`,每物理帧至多一次施法 → 60 次/秒硬顶,实际速率按**整帧量化**,故饱和点 = `(1/60) / 法杖基准间隔`(`circuit_fork` 0.0278 / `wand_basic` 0.0333 / `wand_fast` 0.0667)。原 `hard: 0.01` 远在所有杖的饱和点之下,从饱和点买到 0.01 射速纹丝不动。因饱和点随杖而异、不存在对所有杖都最优的单一 `hard`,取 0.05 为折中:`wand_basic` / `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)`(较原来的 6.7 倍缩至 1.33 倍)。完整实测数据集见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。若日后要把 `wand_fast` 那段也消掉,正确做法是让 `hard` **逐杖化**(挪进 `cores.json`)而非继续挪这个全局数 —— 属独立立项,**不是本期遗留待办**。 2. ~~**货架 C(属性购买)**~~ ✅ **完成**(2026-07-31,`feat/shelf-c-attribute-shop`;spec/plan `docs_dev/{specs,plans}/2026-07-31-shelf-c-attribute-shop*`):`PriceFormula` 纯静态定价模块(`geometric`/`linear`/`flat` 三曲线,`class_name` 零依赖,可脱离游戏进程单测,风格对齐 `AttributeFormula`);`ShopManager` 新增货架 C 状态(`_attr_purchases` 唯一写入点 `_apply_attr_purchases()` 重建加成,来源常量 `MOD_SOURCE_SHOP_C="shop_c"`);`attributes.json` 逐属性加 `shop` 段(`mode`/`step`/`price_base`/`price_growth`/`curve`)驱动可售集合与定价,**无 `shop` 段即不可售**(数据驱动,加属性零代码,运行时实测:注入无 `shop` 段的合成属性会被正确排除);`ProfileManager` schema 2→3 新增 `attr_purchases` 持久化,`apply_run()` 顺序为**先 `ShopManager.apply_attr_purchases_save` 后 `PlayerStats.load_save_data`**(颠倒会在"已有历史购买"场景下吞血,运行时实测复现:错误顺序丢 30 HP、正确顺序不丢);商店 UI 新增「📊 属性」按钮打开独立子面板(原计划设想内联在商店面板,实测面板仅余 18px 放不下,改独立子面板);设计器「属性」页扩展 `shop` 五字段编辑。 - - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;届时只需给对应属性加 `shop` 段即可上架,**零代码**(`get_sellable_attrs()` 按 `shop` 段存在性筛选,已运行时验证)。 + - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;**「加 `shop` 段即可上架、零代码」只在该属性已接入 PlayerStats 框架之后才成立**——上述 7 个都还没有,届时仍需先在 `scripts/autoloads/player_stats.gd`(裸字段 + `_recompute_attrs()` 一行 + `_attr_effective` 字面量各加一条)与 `addons/game_designer/attribute_tab.gd` 的 `ATTR_ORDER` 常量把它接进框架,之后加 `shop` 段才是零代码(2026-08 最终评审修复:`get_sellable_attrs()` 现同时校验 `shop` 段与 `PlayerStats.has_attr()`,未接线的属性会被排除而非被静默售卖——此前的「已运行时验证」结论范围有误,验证的只是"有 shop 段即出现在列表",没验证"未接线属性会怎样")。另外,商店「属性」子面板当前坐标常量(`scenes/main/combat_s2.gd` 的 `_setup_attr_shop_ui`)在关闭按钮之前最多容纳 6 行,7 个属性一次性全部上架会超出,需要先做布局/滚动改造。 - ✅ **`cast_delay_mod` 的 soft 上限验证**:三个饱和点(`wand_basic` 0.0333 等)全部低于货架购买软上限 `soft`(0.1),常规购买够不到饱和区。运行时实测买到 `soft` 需 22 次购买,此时帧距仍为 3 帧(未饱和),证明 `soft` 才是玩家实际能碰到的约束。 3. **货架 B(核心抽取)**:商店提供 Core 抽取/更换(复用 `_CORE_ROSTER`),与现有换杖打通。S~M。 4. **出售退款 G5**:背包内法术/核心可出售,按 `numerical` 退款比例返还货币。S。 diff --git a/scripts/autoloads/player_stats.gd b/scripts/autoloads/player_stats.gd index b76643f..329bb46 100644 --- a/scripts/autoloads/player_stats.gd +++ b/scripts/autoloads/player_stats.gd @@ -263,3 +263,12 @@ func get_attr_value(attr_id: String) -> float: push_error("PlayerStats: 未知属性「%s」,无生效值" % attr_id) return 0.0 return float(_attr_effective[attr_id]) + +## 该属性是否已在框架内实装(即 _recompute_attrs 会为它算出生效值)。 +## 供 ShopManager 等外部读者判定:只有 attributes.json 有 shop 段还不够卖—— +## 若该属性根本没接进 PlayerStats(无裸字段/无 _recompute_attrs 分支/无 _attr_effective 条目), +## 卖出的加成会调用 add_modifier 写入 _modifiers 但永远没有对应的 _compute_attr 分支读取它, +## 玩家花钱买了一个不生效的空气条目。用 _attr_effective(而非 _attr_def)判定,因为 +## _attr_def 只反映 JSON 有没有这一节、不反映代码有没有真的消费它。 +func has_attr(attr_id: String) -> bool: + return _attr_effective.has(attr_id) diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index 7c3456a..e0745ec 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -109,15 +109,44 @@ func _load_attr_definitions() -> void: return _attr_def = parsed -## 可售属性 = 带 shop 段的属性。缺段即不可售(数据驱动,加属性零代码) +## 可售属性 = 带 shop 段「且」已在 PlayerStats 框架内实装的属性。只满足前者不够—— +## 光有 shop 段而 PlayerStats 未接线(无裸字段/_recompute_attrs 分支/_attr_effective 条目) +## 会导致买了空气:扣钱、_attr_purchases 计数增加,但 PlayerStats.get_attr_value 永远读不到 +## 对应的生效值(详见 PlayerStats.has_attr 注释)。数据驱动的「加属性零代码」只在属性已 +## 实装的前提下成立,见 docs_dev/plans/2026-07-23-missing-features-roadmap.md 相应条目订正。 func get_sellable_attrs() -> Array[String]: var out: Array[String] = [] + var unwired: Array[String] = [] for id in _attr_def: - if _attr_def[id].get("shop", null) is Dictionary: - out.append(String(id)) + if not (_attr_def[id].get("shop", null) is Dictionary): + continue + var attr_id: String = String(id) + if PlayerStats.has_attr(attr_id): + out.append(attr_id) + else: + unwired.append(attr_id) # 先收集,诊断挪到本函数返回之后处理,原因见 _report_unwired_shop_attrs out.sort() + if not unwired.is_empty(): + _report_unwired_shop_attrs(unwired) return out +## 诊断故意拆成独立函数、且在 get_sellable_attrs 已经算出 out 之后才调用—— +## 实测 assert(false) 在本项目运行环境下会当场中断「当前函数」的其余执行并返回该函数声明类型 +## 的默认值(此处即空 Array),但不会波及调用方:调用方在函数调用语句之后仍会继续正常执行。 +## 若把 push_error/assert 直接写在 get_sellable_attrs 的收集循环里,一旦命中就会让 +## get_sellable_attrs 本身在此提前中断,返回空数组——不止是排除了那个坏属性,而是连同 +## cpu_limit/move_speed/hp_max/cast_delay_mod 等本来正常的属性也一起从货架上消失, +## 比「静默卖空气」更糟。故诊断必须发生在一次独立的函数调用里,让中断只影响诊断本身。 +func _report_unwired_shop_attrs(unwired: Array[String]) -> void: + for attr_id in unwired: + push_error("ShopManager: 属性「%s」有 shop 段但未接入 PlayerStats 框架,已从可售列表排除" % attr_id) + # 运行中的游戏里 push_error 到不了任何日志通道(已实测,见本项目已知工具坑),故同 + # player_stats.gd:250-257 的既有做法一样补 assert 保证开发期立刻中断可见。本函数只在 + # get_sellable_attrs 检测到不一致时才被调用,而后者只在商店 UI 搭建时调用一次 + #(combat_s2._setup_attr_shop_ui 在 _ready 调用,不在每次刷新的 _refresh_shop_ui 路径上), + # 故这条 assert 不会刷屏。 + assert(false, "ShopManager: shop 段与 PlayerStats 框架不同步:%s" % str(unwired)) + func get_attr_purchases(attr_id: String) -> int: return int(_attr_purchases.get(attr_id, 0)) From 8680e8c0ef73298d722b03c796b3619808832048 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:51:23 +0800 Subject: [PATCH 18/20] =?UTF-8?q?fix(designer):=20=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E9=A1=B5=E4=BB=85=E4=B8=BA=E5=B7=B2=E6=9C=89=20shop=20?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=E5=B1=9E=E6=80=A7=E5=86=99=E5=9B=9E=E8=AF=A5?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _collect() 此前无条件为 ATTR_ORDER 里每个属性写出 entry["shop"](因为 _shop_rows 在 _ready() 里对全部 4 个属性都无条件建了控件行,哪怕原属性没有 shop 段也会以 0 值 占位)。若某属性本来没有 shop 段,保存会写出 price_base=0 的空段,PriceFormula 会把 0 价地板钳到 1G,等于把一个零效果属性变成 1G 无限购买,直接反转货架 C「无 shop 段 即不可售」的核心不变量。 加一道 had_shop 判定:只有 _data 里原本就带 shop 段的属性才写回该段;已有段的属性 继续沿用原有的 duplicate(true) 合并式防丢键行为不变。当前 4 个 ATTR_ORDER 属性都带 shop 段,故此路径今天不可达,需设计器保存才会触发;已用手工构造的 _data/_rows/_shop_rows 调用 _collect() 验证:摘掉 shop 段的属性不再被写回该段,其余属性不受影响。 --- addons/game_designer/attribute_tab.gd | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/addons/game_designer/attribute_tab.gd b/addons/game_designer/attribute_tab.gd index 6dd12db..b92c148 100644 --- a/addons/game_designer/attribute_tab.gd +++ b/addons/game_designer/attribute_tab.gd @@ -125,7 +125,13 @@ func _collect() -> Dictionary: # 存 key,不存双语显示串;selected 恒为 0..2(UI.opt 构造时已 clampi,本页此后不再赋值) entry["combine"] = COMBINE_KEYS[r["combine"].selected] var r2: Dictionary = _shop_rows.get(attr_id, {}) - if not r2.is_empty(): + # 「无 shop 段即不可售」是货架 C 的核心不变量(ShopManager.get_sellable_attrs 靠它筛选 + # 可售集合)。_shop_rows 对 ATTR_ORDER 里每个属性都无条件建了控件行(哪怕原本没有 shop + # 段,行以 0 值占位),所以不能只凭 r2 非空就写回;必须再确认 _data 里原本就有这一段, + # 否则会把 price_base=0 的空段写进 JSON——PriceFormula 把 0 价地板钳到 1G, + # 一个零效果属性从此以 1G 可无限购买,直接违反上述不变量。 + var had_shop: bool = _data.get(attr_id, {}).get("shop", null) is Dictionary + if not r2.is_empty() and had_shop: var shop_entry: Dictionary = entry.get("shop", {}).duplicate(true) shop_entry["mode"] = MODE_KEYS[r2["mode"].selected] # 存 key 不存显示串 shop_entry["step"] = _clean(float(r2["step"].value)) From ce4ce2b3925db069aa24945662da52479b5eeca4 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 12:51:30 +0800 Subject: [PATCH 19/20] =?UTF-8?q?docs(shop):=20=E8=AE=B0=E5=BD=95=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E5=AD=90=E9=9D=A2=E6=9D=BF=E8=A1=8C=E5=AE=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E9=99=90=EF=BC=8C=E4=BE=9B=E5=90=8E=E7=BB=AD=E6=89=A9?= =?UTF-8?q?=E5=AE=B9=E5=89=8D=E7=BD=AE=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商店「属性」子面板行坐标 y = 220 + i*34,关闭按钮占 y=[420,460];按当前坐标常量 算出 250+34i <= 420 ⟺ i <= 5,即最多 6 行(i=0..5)不与关闭按钮重叠,第 7 行 (i=6)落在 y=424 正压在按钮上。路线图 E3-① 延后的 7 个属性一旦全部上架会超出 此容量。本轮不实现滚动/布局改造(超出本次合并范围),仅加注释记录数字,供后续 实现者在动那批属性之前先规划布局。 --- scenes/main/combat_s2.gd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scenes/main/combat_s2.gd b/scenes/main/combat_s2.gd index d045779..3b17e4e 100644 --- a/scenes/main/combat_s2.gd +++ b/scenes/main/combat_s2.gd @@ -398,6 +398,11 @@ func _setup_attr_shop_ui() -> void: _attr_rows.clear() var ids: Array[String] = ShopManager.get_sellable_attrs() + # 行容量上限(当前坐标常量下):行 i 占 y=[220+34i, 250+34i](label/button 高 30), + # 关闭按钮占 y=[420, 460](见下方 close.position/size)。250+34i <= 420 ⟺ i <= 5, + # 即最多 6 行(i=0..5)不与关闭按钮重叠;第 7 行(i=6)落在 y=424,正压在按钮上。 + # 路线图 E3-② 延后的 7 个属性一旦全部上架会超出此容量,需要先做布局/滚动改造—— + # 本轮评审 Finding 4 仅要求记录容量,不在此实现滚动,故不改动下方坐标。 for i in ids.size(): var id: String = ids[i] var y: float = 220.0 + float(i) * 34.0 From 06f0049221ba16b43a553c002af5465516a26f44 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 3 Aug 2026 13:02:20 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(shop):=20can=5Fbuy=5Fattribute=20?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E6=A0=A1=E9=AA=8C=E5=B1=9E=E6=80=A7=E5=B7=B2?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=9B=E8=AE=A2=E6=AD=A3=E8=B7=AF=E7=BA=BF?= =?UTF-8?q?=E5=9B=BE=E6=AE=8B=E7=95=99=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 购买路径是独立守门人:货架 B / 出售退款等后来者会直接调 can_buy_attribute 而不必先过 get_sellable_attrs,只在展示侧拦截不足以保住不变量。未接线属性 生效值恒为 0,_at_soft_cap 因此永不封顶,可被无限买成空气。 同时把路线图第 99 行残留的「加属性零代码」括注收敛到下一条 ⚠️ 订正,避免 同一条目里两句话互相矛盾。 --- docs_dev/plans/2026-07-23-missing-features-roadmap.md | 2 +- scripts/autoloads/shop_manager.gd | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs_dev/plans/2026-07-23-missing-features-roadmap.md b/docs_dev/plans/2026-07-23-missing-features-roadmap.md index 45d742b..bd4f646 100644 --- a/docs_dev/plans/2026-07-23-missing-features-roadmap.md +++ b/docs_dev/plans/2026-07-23-missing-features-roadmap.md @@ -96,7 +96,7 @@ E6-a 玩家无敌帧(i-frames) ← 先行小项:Boss 弹幕公平性前置, - `recharge_speed_mod` —— 代码中**不存在充能系统**(全项目 grep `recharge` 零命中),属新增机制而非断线。 - 另:`mana_max` 的「与法杖取 Min」语义(权威 §1.1)未迁入框架,现状是核心值直接覆盖玩家值;那是独立设计判断,记为已知遗留。 - ✅ **`cast_delay_mod` 的饱和区问题已处理(`hard: 0.01 → 0.05`,提交 `ba7c885`,决定已关闭)**:`_handle_auto_cast` 用 `if` 而非 `while`,每物理帧至多一次施法 → 60 次/秒硬顶,实际速率按**整帧量化**,故饱和点 = `(1/60) / 法杖基准间隔`(`circuit_fork` 0.0278 / `wand_basic` 0.0333 / `wand_fast` 0.0667)。原 `hard: 0.01` 远在所有杖的饱和点之下,从饱和点买到 0.01 射速纹丝不动。因饱和点随杖而异、不存在对所有杖都最优的单一 `hard`,取 0.05 为折中:`wand_basic` / `circuit_fork` 无无效区间,`wand_fast` 仍剩 `[0.05, 0.0667)`(较原来的 6.7 倍缩至 1.33 倍)。完整实测数据集见 `docs_dev/specs/2026-07-31-player-attributes-design.md` §2.2b。若日后要把 `wand_fast` 那段也消掉,正确做法是让 `hard` **逐杖化**(挪进 `cores.json`)而非继续挪这个全局数 —— 属独立立项,**不是本期遗留待办**。 -2. ~~**货架 C(属性购买)**~~ ✅ **完成**(2026-07-31,`feat/shelf-c-attribute-shop`;spec/plan `docs_dev/{specs,plans}/2026-07-31-shelf-c-attribute-shop*`):`PriceFormula` 纯静态定价模块(`geometric`/`linear`/`flat` 三曲线,`class_name` 零依赖,可脱离游戏进程单测,风格对齐 `AttributeFormula`);`ShopManager` 新增货架 C 状态(`_attr_purchases` 唯一写入点 `_apply_attr_purchases()` 重建加成,来源常量 `MOD_SOURCE_SHOP_C="shop_c"`);`attributes.json` 逐属性加 `shop` 段(`mode`/`step`/`price_base`/`price_growth`/`curve`)驱动可售集合与定价,**无 `shop` 段即不可售**(数据驱动,加属性零代码,运行时实测:注入无 `shop` 段的合成属性会被正确排除);`ProfileManager` schema 2→3 新增 `attr_purchases` 持久化,`apply_run()` 顺序为**先 `ShopManager.apply_attr_purchases_save` 后 `PlayerStats.load_save_data`**(颠倒会在"已有历史购买"场景下吞血,运行时实测复现:错误顺序丢 30 HP、正确顺序不丢);商店 UI 新增「📊 属性」按钮打开独立子面板(原计划设想内联在商店面板,实测面板仅余 18px 放不下,改独立子面板);设计器「属性」页扩展 `shop` 五字段编辑。 +2. ~~**货架 C(属性购买)**~~ ✅ **完成**(2026-07-31,`feat/shelf-c-attribute-shop`;spec/plan `docs_dev/{specs,plans}/2026-07-31-shelf-c-attribute-shop*`):`PriceFormula` 纯静态定价模块(`geometric`/`linear`/`flat` 三曲线,`class_name` 零依赖,可脱离游戏进程单测,风格对齐 `AttributeFormula`);`ShopManager` 新增货架 C 状态(`_attr_purchases` 唯一写入点 `_apply_attr_purchases()` 重建加成,来源常量 `MOD_SOURCE_SHOP_C="shop_c"`);`attributes.json` 逐属性加 `shop` 段(`mode`/`step`/`price_base`/`price_growth`/`curve`)驱动可售集合与定价,**无 `shop` 段即不可售**(运行时实测:注入无 `shop` 段的合成属性会被正确排除;「零代码」的适用范围见下条 ⚠️ 订正);`ProfileManager` schema 2→3 新增 `attr_purchases` 持久化,`apply_run()` 顺序为**先 `ShopManager.apply_attr_purchases_save` 后 `PlayerStats.load_save_data`**(颠倒会在"已有历史购买"场景下吞血,运行时实测复现:错误顺序丢 30 HP、正确顺序不丢);商店 UI 新增「📊 属性」按钮打开独立子面板(原计划设想内联在商店面板,实测面板仅余 18px 放不下,改独立子面板);设计器「属性」页扩展 `shop` 五字段编辑。 - ⚠️ **实际范围**:仅落地 `attributes.json` 现有的 4 个框架内属性(`hp_max`/`move_speed`/`cast_delay_mod`/`cpu_limit`)。E3-① 延后的 B 类 7 个属性(`attunement_*` ×4、`luck`、`recharge_speed_mod`、`mana_max` 的 Min 语义)仍待各自前置(暴击链、充能系统、元素伤害管线等)解除后才有意义可买;**「加 `shop` 段即可上架、零代码」只在该属性已接入 PlayerStats 框架之后才成立**——上述 7 个都还没有,届时仍需先在 `scripts/autoloads/player_stats.gd`(裸字段 + `_recompute_attrs()` 一行 + `_attr_effective` 字面量各加一条)与 `addons/game_designer/attribute_tab.gd` 的 `ATTR_ORDER` 常量把它接进框架,之后加 `shop` 段才是零代码(2026-08 最终评审修复:`get_sellable_attrs()` 现同时校验 `shop` 段与 `PlayerStats.has_attr()`,未接线的属性会被排除而非被静默售卖——此前的「已运行时验证」结论范围有误,验证的只是"有 shop 段即出现在列表",没验证"未接线属性会怎样")。另外,商店「属性」子面板当前坐标常量(`scenes/main/combat_s2.gd` 的 `_setup_attr_shop_ui`)在关闭按钮之前最多容纳 6 行,7 个属性一次性全部上架会超出,需要先做布局/滚动改造。 - ✅ **`cast_delay_mod` 的 soft 上限验证**:三个饱和点(`wand_basic` 0.0333 等)全部低于货架购买软上限 `soft`(0.1),常规购买够不到饱和区。运行时实测买到 `soft` 需 22 次购买,此时帧距仍为 3 帧(未饱和),证明 `soft` 才是玩家实际能碰到的约束。 3. **货架 B(核心抽取)**:商店提供 Core 抽取/更换(复用 `_CORE_ROSTER`),与现有换杖打通。S~M。 diff --git a/scripts/autoloads/shop_manager.gd b/scripts/autoloads/shop_manager.gd index e0745ec..8245229 100644 --- a/scripts/autoloads/shop_manager.gd +++ b/scripts/autoloads/shop_manager.gd @@ -164,6 +164,11 @@ func can_buy_attribute(attr_id: String) -> String: var sp = d.get("shop", null) if not (sp is Dictionary): return "该属性不可购买" + # 与 get_sellable_attrs 同一条不变量,须在此再判一次:UI 只列可售项,但本函数是购买路径的 + # 独立守门人(货架 B / 出售退款等后来者会直接调它,未必先过 get_sellable_attrs)。 + # 缺这条则未接线属性可被买成空气,且 _at_soft_cap 因生效值恒为 0 永不封顶 → 可无限购买 + if not PlayerStats.has_attr(attr_id): + return "该属性不可购买" if _at_soft_cap(attr_id, d): return "已达上限" if PlayerStats.gold < get_attr_price(attr_id):