fix(shop): 可售属性排除未接线项,修正路线图「零代码」表述

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 行的限制,供后续实现者提前规划。
This commit is contained in:
2026-08-03 12:51:13 +08:00
parent 56d7eefdc8
commit 5889b3c1e5
3 changed files with 42 additions and 4 deletions
+9
View File
@@ -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)
+32 -3
View File
@@ -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))