按 Task 5 Step 8:每个因评审而变的代码块与断言脚本都替换为真正跑通的版本, 逐处附「原写法为何不可用」——这才是回填的持久价值,否则下次复跑喂出的是 过时代码和可能已失效的断言(归航那期踩过)。 主要回填:AttributeFormula 的 pct 越界守卫与静态类型;PlayerStats 的 add_modifier 校验 / hp 钳制 / reset_for_run 清空 / 空定义分支用 assert;守卫从施法处移到 equip_wand; cast_delay_mod 改开火时生效(原计划的装备时折叠会让局中加成到下次换杖才生效, 正好废掉货架 C);补 combat_test 注入这个原计划遗漏的消费方;属性页 SpinBox 下限、 _loaded 拒写回、autowrap、两条 push_warning。 订正计划自己的一句假话:原写「四个属性的值全部落在 0.01 格点上,故往返无量化损失」。 实测 min=-99999 时 0.1→0.100000000005821、0.01→0.00999999999476131,二者恰恰都在 0.01 格点上——成因是 Range 吸附式在 min 与 step 相差七个数量级时的抵消误差,与格点 无关;而 0.0001 容差比该漂移大七个数量级,原断言在有 bug 的版本上也是绿的。改为对 落盘文本逐字节比较。 Task 5 另记两条工具坑:运行中游戏的 push_error/push_warning 不进 MCP 的两个日志通道 (只有 print 进),故「跑一局看日志无报错」本身是空断言,必须读编辑器 Debugger 错误页 并先用故意错误做正对照;execute_game_script 不支持 await、三引号会被外层包装破坏。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71 KiB
玩家属性系统(Player Attributes)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.⚠️ 本文件已于 2026-07-31(Task 5 Step 8)回填为「实际执行版」。 每个任务的代码块与断言脚本都已替换为真正落地并跑通的版本,评审改动处附「原写法为何不可用」。按本计划复跑得到的就是仓库里的代码。未回填的计划会在下一次复跑时喂出过时代码 —— 归航那期踩过这个坑。
Goal: 建立数据驱动的玩家属性框架(含独立的加成公式模块),接线三条既有死数据(cpu_limit / move_speed / cast_delay_mod),并为货架 C 提供 add_modifier 接入点。
Architecture: 公式与状态分离。AttributeFormula(class_name 纯静态模块,零依赖)是所有合成公式的唯一实现处,因此可脱离游戏进程单元断言;PlayerStats 只负责加载 attributes.json、持有加成列表、把公式结果写进静态类型裸字段(读取端零开销)。法杖的 cpu_limit 以 source="core" 的加成来源接入(换杖时重算,与既有 mana_leech 同处同时机),使 hard 上限天然作用于生效总值。
Tech Stack: Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro(公式为纯函数 → execute_editor_script 单元断言;接线为运行时 execute_game_script 实测)。
规范:提交中文;纯数据驱动,文件缺失
push_error不静默回退。spec:docs_dev/specs/2026-07-31-player-attributes-design.md。 已知工具坑(① – ⑦ 为实现期所得,⑧ – ⑩ 为 Task 5 验收期新增): ①validate_script对任何带class_name的脚本必然假阴性(报hides a global script class)——attribute_formula.gd属此类;player_stats.gd/player_manager.gd/spell_evaluator.gd/combat_manager.gd/attribute_tab.gd/designer_panel.gd无class_name,可正常validate_script。 ② 验证class_name脚本的首选手段(Task 1 实测得出,优于下面两种绕法):var F = ResourceLoader.load(path, "GDScript", ResourceLoader.CACHE_MODE_IGNORE)强制绕过引擎缓存、从磁盘重新编译,保留
class_name原样且走引擎自己的编译器 —— 因此它顺带证明了静态类型标注(如Dictionary[String, Combine]、for m: Dictionary)真能被引擎编译,而不只是能被动态副本解析。无需restart_editor,也不必改源码。 ③ ⚠️ 直接调用已注册的全局类名会拿到过时结果,是假通过陷阱。 Task 1 修完文件后经AttributeFormula.compute(...)直调,返回的仍是修复前的数值 —— 编辑器持有的已编译类是旧的。改完源码后不要用全局类名验证,用 ② 的CACHE_MODE_IGNORE。 ④GDScript.new()+source_code+reload()这个绕法,在该类被注册为全局类之后会失败(同一条hides a global script class);若要用必须先src.replace("class_name AttributeFormula\n", "")剥掉那行。@tool编辑器类在 dock 里被缓存,同理。优先用 ②(attribute_tab.gd无class_name,用 ④ 即可)。 ⑤var x := <Variant 方法调用>在 4.7.1 是编译错误而非警告,且报错无行号(Script compilation failed),极难定位。断言夹具里若持有动态GDScript引用F,必须写var r: float = F.compute(...)而非var r := F.compute(...)。 ⑥execute_editor_script静默阻止运行时FileAccess.WRITE。 ⑦ 新增.gd后.gd.uid不会自动出现,需EditorInterface.get_resource_filesystem().scan()触发扫描才生成;项目约定二者一并提交。 ⑧ ⚠️⚠️ 运行中游戏的push_error/push_warning既不进get_output_log也不进get_editor_errors(Task 5 实测)。 只有运行时print()会进 output 面板;编辑器侧的push_error两个通道都进。因此「跑一局看日志里没有报错」这类断言本身是空的 —— 它分不清「守卫没误报」和「守卫报了但看不见」。真正的运行时错误通道是编辑器 Debugger 的「错误」页,读法见 Task 5 Step 1b。任何声称「零报错」的断言,必须先用一条故意的错误做正对照,证明通道是活的。 ⑨execute_game_script不支持await(Trying to call an async function without "await"并使该命令崩溃)。需要跨帧观测时,向get_tree().root挂一个带_physics_process的临时节点,它在多次 MCP 调用之间存活。 ⑩execute_game_script的三引号字符串会被外层包装破坏(报Parse error)。构造子脚本源码请用"\n".join([...])的行数组。 关键既有事实:spell_evaluator.gd:332var max_ops: int = MAX_OPS_PER_CPU * 5(MAX_OPS_PER_CPU = 40,:8)。player_manager.gd:7const MOVE_SPEED: float = 200.0,:45消费,:88_cast_interval = core.cast_interval if core else 0.5。combat_manager.gd:257_rebuild_wand(),:267-271为mana_leech重算段。player_stats.gd:hp_max:10、cpu_limit:21、armor:22、resistance:23、_ready():33、reset_for_run():125、get_save_data():135、load_save_data():138。CompiledDeck没有cpu_limit字段(只有feature_tags),故spell_evaluator侧不需要新增字段。
Task 0: 建功能分支
- Step 1: 从 master 切分支
spec 与本计划已提交在 master;CLAUDE.md 规定主分支上不做功能开发。
git checkout -b feat/player-attributes
Expected: Switched to a new branch 'feat/player-attributes'
Task 1: AttributeFormula 公式模块(TDD)
Files:
- Create:
scripts/domain/attribute_formula.gd - Create:
scripts/domain/attribute_formula.gd.uid(Godot 自动生成,随后提交)
本任务是纯函数,先写断言、看它失败、再实现。断言脚本不落盘为文件(项目无测试框架),而是经
execute_editor_script执行 —— 与既有验收方式一致。
- Step 1: 先跑断言脚本,确认它因类不存在而失败
Run: godot-mcp-pro execute_editor_script:
_mcp_print("exists=%s" % str(ClassDB.class_exists("AttributeFormula") or ResourceLoader.exists("res://scripts/domain/attribute_formula.gd")))
Expected: exists=false。若为 true 说明文件已存在,先确认不是残留。
- Step 2: 实现
AttributeFormula
Create scripts/domain/attribute_formula.gd(以下为实际落地版本,含评审后的四处改动):
## AttributeFormula — 属性合成公式的唯一实现处
## 纯静态函数:无状态、不依赖 PlayerStats / EventBus / 场景树,故可脱离游戏进程单元测试
## 权威来源:docs_dev/specs/2026-07-31-player-attributes-design.md §2.1 / §2.3
class_name AttributeFormula
extends RefCounted
enum Combine { HYBRID, INVERSE, ADD_INT }
const _COMBINE_BY_NAME: Dictionary[String, Combine] = {
"hybrid": Combine.HYBRID,
"inverse": Combine.INVERSE,
"add_int": Combine.ADD_INT,
}
## JSON 的 combine 字符串 → 枚举;未知值 push_error 并回退 HYBRID
static func combine_from_string(s: String) -> Combine:
if _COMBINE_BY_NAME.has(s):
return _COMBINE_BY_NAME[s]
push_error("AttributeFormula: 未知 combine「%s」,回退 hybrid" % s)
return Combine.HYBRID
## 唯一的公式入口
## base —— attributes.json 的基准值
## mods —— [{"mode": "flat"|"pct", "value": float}, ...];调用方负责只传本属性的加成
## 契约:仅由 PlayerStats.add_modifier 构造,故假定 mode/value 键必存在且类型正确;
## 此处不做防御性校验(未知 mode 会 push_error,但缺键会被静默当默认值)
## attr_def —— attributes.json 中该属性的定义(读 combine / hard)
## hard 的语义**因 combine 而异**,勿混淆:
## · hybrid —— hard 是上限;`hard == 0.0` 约定为「不钳制」(如 hp_max 无硬上限)
## · add_int —— 同上,hard 是上限,`0.0` 即不钳制
## · inverse —— hard 是**下限**;此处 `0.0` **不是**「不钳制」而是下限 0,与上二者相反
## 返回统一为 float;add_int 属性由调用方做 int() 转换(公式模块不感知目标字段类型)
static func compute(base: float, mods: Array, attr_def: Dictionary) -> float:
var combine: Combine = combine_from_string(String(attr_def.get("combine", "hybrid")))
var hard: float = float(attr_def.get("hard", 0.0))
var flat_sum: float = 0.0
var pct_prod: float = 1.0
for m: Dictionary in mods:
var mode: String = String(m.get("mode", "flat"))
var v: float = float(m.get("value", 0.0))
if mode == "flat":
flat_sum += v
elif mode == "pct":
if combine == Combine.ADD_INT:
push_error("AttributeFormula: add_int 属性不接受 pct 加成(value=%f),已忽略" % v)
continue
# 连乘而非线性求和:三条 +20% = ×1.728 而非 ×1.6,避免后期线性失控
var f: float = (1.0 + v) if combine == Combine.HYBRID else (1.0 - v)
if f < 0.0:
# 越界的 pct 会使因子变负;两条负因子相乘会变回正数,产生无报错的错数
push_error("AttributeFormula: pct 越界(value=%f → 因子 %f),已钳到 0" % [v, f])
f = 0.0
pct_prod *= f
else:
push_error("AttributeFormula: 未知 mode「%s」,已忽略" % mode)
match combine:
Combine.ADD_INT:
var floored: float = floorf(base + flat_sum)
# 下界 0:生效值不允许为负(负的运算力无意义)
return clampf(floored, 0.0, hard) if hard > 0.0 else maxf(0.0, floored)
Combine.INVERSE:
# 越低越快:hard 是**下限**。注意 hard=0.0 在此处不是「不钳制」而是下限 0,与 hybrid 相反
return maxf(hard, (base + flat_sum) * pct_prod)
_:
# 下界 0:生效值不允许为负(负的移速无意义)
var r: float = maxf(0.0, (base + flat_sum) * pct_prod)
return minf(r, hard) if hard > 0.0 else r # hard=0.0 约定为「不钳制」
📌 回填说明 —— 原写法为何不可用(四处,均为 Task 1 评审所改):
pct越界守卫(新增):原实现直接pct_prod *= (1.0 + v)。若两条pct都越界(如各-2.0),因子是(-1) × (-1) = +1,结果等于完全没加成,返回一个看起来完全合理的正数且零诊断。这类"负负得正"是最难查的一种错。现在单条因子一旦为负就push_error并钳到 0,越界的后果变成显眼的 0 而非隐形的原值。_COMBINE_NAMES→_COMBINE_BY_NAME+Dictionary[String, Combine]:原名读作"combine 的名字们",实际是"按名字查 combine";加静态类型后写错枚举值在编译期就报,而不是运行时静默塞进一个 int。ri→floored:ri(round int)既非 round 也非 int,纯噪声名。hard语义差异写进函数头注释:inverse的hard是下限、0.0不表示"不钳制",与另两种相反。这条差异原本只存在于实现者脑中,设计师给inverse属性填hard: 0期待"不限制"会得到相反结果。 另:for m: Dictionary in mods的静态类型标注只有经工具坑 ② 的CACHE_MODE_IGNORE才真正被引擎编译器验证过(动态副本能解析 ≠ 引擎能编译)。
- Step 3: 跑公式单元断言(spec §4 验收 1–6)
Run: godot-mcp-pro execute_editor_script:
# 工具坑 ②:CACHE_MODE_IGNORE 从磁盘重编译,保留 class_name,走引擎真编译器
var F = ResourceLoader.load("res://scripts/domain/attribute_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
if F == null:
_mcp_print("LOAD_FAIL"); return
var HYB := {"combine": "hybrid", "hard": 0.0}
var HYB_CAP := {"combine": "hybrid", "hard": 800.0}
var INV := {"combine": "inverse", "hard": 0.01}
var INT := {"combine": "add_int", "hard": 50.0}
# ⚠️ 不要把断言写进 lambda 再累加外层 int —— GDScript 闭包对 int 是**按值捕获**,
# `fails += 1` 传不回外层,FAILS 会恒为 0,整个断言变空。用 Array 逐条收集。
var m3 := [{"mode":"pct","value":0.2},{"mode":"pct","value":0.2},{"mode":"pct","value":0.2}]
var many := []
for i in 20: many.append({"mode":"pct","value":0.5})
var oob2 := [{"mode":"pct","value":-2.0},{"mode":"pct","value":-2.0}]
# ⚠️ 工具坑 ⑤:F 是 Variant,必须写 `var r: float = F.compute(...)`,
# 写成 `var r := F.compute(...)` 是**编译错误且不报行号**。
var r1: float = F.compute(1.0, m3, HYB)
var r2: float = F.compute(200.0, [{"mode":"flat","value":50.0},{"mode":"pct","value":0.2}], HYB)
var r3: float = F.compute(1.0, m3, INV)
var r3b: float = F.compute(1.0, many, INV)
var r4: float = F.compute(0.0, [{"mode":"flat","value":5.0},{"mode":"pct","value":0.5},{"mode":"flat","value":8.0}], INT)
var r5: float = F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB_CAP)
var r5b: float = F.compute(200.0, [{"mode":"flat","value":5000.0}], HYB)
var r6: float = F.compute(123.0, [], HYB)
var r6b: float = F.compute(1.0, m3, {"combine": "bogus", "hard": 0.0})
var r6c: float = F.compute(0.0, [{"mode":"flat","value":999.0}], INT)
var r7: float = F.compute(200.0, oob2, HYB)
var r7b: float = F.compute(1.0, [{"mode":"pct","value":2.0}], INV)
var cases := [
["①连乘", r1, 1.728],
["②混合", r2, 300.0],
["③inverse", r3, 0.512],
["③b下限", r3b, 0.01],
["④拒pct", r4, 13.0],
["⑤上限", r5, 800.0],
["⑤b不钳制", r5b, 5200.0],
["⑥空", r6, 123.0],
["⑥b未知", r6b, 1.728],
["⑥c整数上限", r6c, 50.0],
["⑦越界不回正", r7, 0.0], # 两条 -200%:若无守卫会得 200(负负得正)
["⑦b越界inverse", r7b, 0.01], # 单条 +200% → 因子 -1 → 钳 0 → 落到 hard 下限
]
var fails := 0
var bad := []
for c in cases:
if abs(float(c[1]) - float(c[2])) > 0.0001:
fails += 1
bad.append("%s got=%.6f want=%.6f" % [c[0], c[1], c[2]])
_mcp_print("FAILS=%d/%d %s" % [fails, cases.size(), str(bad)])
Expected(实测 2026-07-31):FAILS=0/12 []
📌 回填说明 —— 原写法为何不可用:
- 原夹具用
GDScript.new()+source_code+reload()。AttributeFormula被注册为全局类之后,这个内存副本会因class_name冲突报hides a global script class而reload()失败(首次运行侥幸通过只是因为当时还没注册)。CACHE_MODE_IGNORE不需要剥class_name,且走引擎真编译器。- 原
cases只有 10 条,把F.compute(...)直接写在数组字面量里。补第 11/12 条越界断言时若图省事写var r7 := F.compute(...),会撞上工具坑 ⑤ 的无行号编译错误 —— 故统一改成显式var rN: float =抽取。⚠️ 断言 ④ 的数字部分对「拒绝
pct」是空的(Task 1 评审实测发现):把整个拒绝块删掉,④ 仍返回 13.0 —— 因为ADD_INT的 match 分支根本不读pct_prod。数字只证明了「pct之后的flat仍能累加」。拒绝行为唯一的可观测证据是push_error,必须单独断言(Step 3b),否则意图②无人守卫。
- Step 3b: 断言
push_error真的发出(补 ④ 的空缺 + 新增的越界守卫)
用唯一标记值发起调用,再从编辑器错误日志里找它 —— 标记值确保读到的不是历史遗留日志(工具坑:clear_output 不会清空 get_editor_errors 的缓冲区,旧行会一直在)。
Run: godot-mcp-pro clear_output,然后 execute_editor_script:
var F = ResourceLoader.load("res://scripts/domain/attribute_formula.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
if F == null:
_mcp_print("LOAD_FAIL"); return
# 四条各带唯一标记,逐一触发报错路径
F.compute(0.0, [{"mode":"pct","value":0.123456}], {"combine":"add_int","hard":50.0})
F.compute(1.0, [], {"combine":"zzzprobe9182","hard":0.0})
F.compute(1.0, [{"mode":"zzzmode9182","value":1.0}], {"combine":"hybrid","hard":0.0})
F.compute(1.0, [{"mode":"pct","value":-9.182}], {"combine":"hybrid","hard":0.0})
_mcp_print("probes_fired")
再 Run: godot-mcp-pro get_editor_errors,断言四条标记均出现(实测 2026-07-31 全部命中):
add_int 属性不接受 pct 加成(value=0.123456未知 combine「zzzprobe9182」未知 mode「zzzmode9182」pct 越界(value=-9.182000 → 因子 -8.182000)
Expected: 四条全部命中。任一缺失即说明对应的报错路径未执行 —— 那是真缺陷,不是日志问题。
📌 回填说明:原脚本用
GDScript.new()+.replace("class_name AttributeFormula\n", "")剥掉class_name才能编译。CACHE_MODE_IGNORE不需要改源码,少一处「测的东西和跑的东西不是同一份」的风险。第 4 条探针(pct越界)随 Step 2 的新守卫一并补入。
- Step 4: 提交
git add scripts/domain/attribute_formula.gd scripts/domain/attribute_formula.gd.uid
git commit -F- <<'EOF'
feat(attr): AttributeFormula 公式模块——hybrid 连乘 / inverse 反向下限 / add_int 拒 pct
公式集中于单一纯静态模块,无状态零依赖,故可脱离游戏进程单元断言。
乘算用连乘而非线性求和:三条 +20% 得 ×1.728 而非 ×1.6,避免后期线性失控。
add_int 对离散预算拒绝 pct 并 push_error,而非静默取整掩盖配置错误。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 2: attributes.json + PlayerStats 框架 + 删孤儿字段
Files:
-
Create:
data/attributes.json -
Modify:
scripts/autoloads/player_stats.gd -
Step 1: 新建
data/attributes.json(与原计划一致,未改)
{
"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.01, "combine": "inverse" },
"hp_max": { "display_name": "最大生命 hp_max", "base": 100.0, "soft": 2000.0, "hard": 0.0, "combine": "hybrid" }
}
- Step 2:
PlayerStats加属性框架
player_stats.gd:将 var hp_max: float = 100.0(:10)保留(生效值字段),并在「战斗属性」区(:21-23)整段替换 —— 删 cpu_limit 旧声明、删 armor、删 resistance,改为(以下为实际落地版本):
# ── 数据源 ────────────────────────────────────────────
const ATTRIBUTES_JSON: String = "res://data/attributes.json"
# ...(HP / 资源 / 等级区不变)...
# ── 属性(生效值:静态类型裸字段,读取端零开销)─────────────
# 由 _recompute_attrs() 经 AttributeFormula 从 base + _modifiers 算出,勿直接赋值
var cpu_limit: int = 0 # 生效运算力;MAX_OPS = 本值 × 40
# 法杖份额待 combat_manager 以 source="core" 注入(Task 3 接线后)
var move_speed: float = 200.0 # 像素/秒
var cast_delay_mod: float = 1.0 # 施法间隔乘算系数,越低越快
var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now + iframe 窗口
# 属性定义与加成来源(非热路径)
var _attr_def: Dictionary = {} # attributes.json 全量定义,只读
var _modifiers: Array[Dictionary] = [] # [{attr_id, mode, value, source}]
(_invuln_until_msec 原在 :24,保持不动,此处只是展示相邻上下文。)
在 _ready()(:33)开头加载定义并首次重算(必须早于任何存档回读):
func _ready() -> void:
_load_attr_definitions()
_recompute_attrs()
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
reset_for_run() 改为(顺序要紧):
func reset_for_run() -> void:
_modifiers.clear() # 局内加成不跨局;"core" 随后由 _rebuild_wand 重建
_recompute_attrs() # 必须早于 hp = hp_max,否则 hp 会用陈旧的 hp_max 播种
hp = hp_max
mana = mana_max
gold = 0
xp = 0
level = 1
xp_to_next = xp_for_level(1)
_invuln_until_msec = 0
stats_changed.emit()
在文件末尾新增:
# ── 属性框架 ──────────────────────────────────────────────
func _load_attr_definitions() -> void:
if not FileAccess.file_exists(ATTRIBUTES_JSON):
push_error("PlayerStats: 缺少 %s" % ATTRIBUTES_JSON)
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(ATTRIBUTES_JSON))
if not (parsed is Dictionary):
push_error("PlayerStats: %s 格式错误(应为对象)" % ATTRIBUTES_JSON)
return
# 逐条守卫:畸形条目(如 "move_speed": 200)会让 _compute_attr 的 Dictionary 赋值硬崩
for k in parsed:
if not (parsed[k] is Dictionary):
push_error("PlayerStats: attributes.json 的「%s」应为对象,整个文件已拒绝加载" % k)
return
_attr_def = parsed
## 追加一条加成来源;同一 source 可对多个属性各加一条
## 本函数是 AttributeFormula.compute 的守门人——公式模块明文假定 mode/value 合法且不再校验,
## 故非法 attr_id / mode 必须在此拦下:否则错条目会永远堆在 _modifiers 里且不产生任何诊断
func add_modifier(attr_id: String, mode: String, value: float, source: String) -> void:
if not _attr_def.has(attr_id):
push_error("PlayerStats: 未知属性「%s」(来源 %s),加成已忽略" % [attr_id, source])
return
if mode != "flat" and mode != "pct":
push_error("PlayerStats: 未知 mode「%s」(%s→%s),加成已忽略" % [mode, source, attr_id])
return
_modifiers.append({"attr_id": attr_id, "mode": mode, "value": value, "source": source})
_recompute_attrs()
## 撤销某来源的全部加成(换杖、出售退款用)
func remove_modifiers_from(source: String) -> void:
var kept: Array[Dictionary] = []
for m in _modifiers:
if String(m.get("source", "")) != source:
kept.append(m)
if kept.size() == _modifiers.size():
return # 无命中:不 emit stats_changed,避免无谓的 HUD / 商店刷新
_modifiers = kept
_recompute_attrs()
func _mods_for(attr_id: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for m in _modifiers:
if String(m.get("attr_id", "")) == attr_id:
out.append(m)
return out
## 逐属性按各自 combine 公式重算生效值,写回裸字段(加成增删后调用)
func _recompute_attrs() -> void:
if _attr_def.is_empty():
# 定义未加载,根因已由 _load_attr_definitions push_error。此处不重复报错:本函数在
# 换杖/换牌/购买时被频繁调用,无条件报错会刷屏并埋掉那条根因。assert 在 release 被
# 编译掉,开发期则由 _ready 的首次调用立即中断。
assert(false, "PlayerStats: 属性定义未加载,add_modifier / 重算全部失效")
return
cpu_limit = int(_compute_attr("cpu_limit"))
move_speed = _compute_attr("move_speed")
cast_delay_mod = _compute_attr("cast_delay_mod")
hp_max = _compute_attr("hp_max")
hp = minf(hp, hp_max) # hp_max 下调(出售退款/换杖)时避免 hp > hp_max
stats_changed.emit()
func _compute_attr(attr_id: String) -> float:
var d: Dictionary = _attr_def.get(attr_id, {})
if d.is_empty():
push_error("PlayerStats: attributes.json 缺少属性「%s」" % attr_id)
return 0.0
return AttributeFormula.compute(float(d.get("base", 0.0)), _mods_for(attr_id), d)
📌 回填说明 —— 原写法为何不可用(六处,均为 Task 2 评审所改):
add_modifier的两条校验(新增):原实现无条件_modifiers.append(...)。AttributeFormula.compute的函数头明文写着"假定 mode/value 合法且不再校验",所以PlayerStats是唯一的守门人。少了它,一句add_modifier("cpu_limt", ...)(打字错)会永远堆在_modifiers里、永不生效、零诊断,而症状(法术预算退化)离根因极远。_recompute_attrs的hp = minf(hp, hp_max)(新增):hp_max是派生值,出售退款/换杖会让它下调。没有这句,HP 会停在150/100这种状态,且heal()因min(hp_max, ...)永远无效 —— 一个悄无声息的坏状态。reset_for_run清_modifiers并在hp = hp_max之前重算(新增):原版两者都没有。不清会让上一局的加成跨局泄漏;不重算就赋hp = hp_max会用上一局残留的hp_max播种本局血量。- 空定义分支用
assert(false, ...)而非裸return:裸return是完全静默的。push_error又不行 —— 本函数在换杖/换牌/购买时被频繁调用,会刷屏并把_load_attr_definitions那条真正的根因报错挤出日志。assert在开发期由_ready()的首次调用立即中断,在 release 被编译掉,正好。_load_attr_definitions的逐条is Dictionary守卫(新增):"move_speed": 200(漏了对象包装)这种畸形 JSON 会让_compute_attr里的var d: Dictionary = ...硬崩,而不是报错。整文件拒绝加载 + 明确报错。_ATTR_PATH→ATTRIBUTES_JSON并上移到文件头的「数据源」区:它是数据源路径,与其它 autoload 的同类常量命名一致;下划线前缀暗示"私有实现细节",但设计器/测试都需要引用它。 另:_mods_for的返回类型由Array收紧为Array[Dictionary](与_modifiers同型)。
- Step 3: 存档字段清理(
hp_max/cpu_limit现为派生值)
两者现在由 attributes.json + _modifiers 算出,存档回读会覆盖公式结果。get_save_data()(:135)与 load_save_data()(:138)改为(含实际落地的注释块):
## hp_max / cpu_limit 不入存档:二者现为 attributes.json + _modifiers 的派生值,
## 回读会覆盖公式结果。旧存档里的同名键忽略即可,无需提升 schema 版本。
##
## 留给货架 C:一旦玩家可购买属性,需要持久化的是 _modifiers(来源列表)而非派生的
## 生效值,届时本函数应加 "attr_modifiers": _modifiers 并提升 schema 版本。
## · source == "core" 的那条**不要**持久化——它在换杖时由 combat_manager._rebuild_wand 重建。
## · 回读须用 _modifiers.assign(...):JSON.parse_string / data.get(..., []) 产出的是无类型
## Array,直接 `=` 赋给 Array[Dictionary] 会运行时类型错误。
## · 回读顺序:_modifiers 必须先 assign + _recompute_attrs(),再赋 hp。反过来会让
## _recompute_attrs 的 hp = minf(hp, hp_max) 拿未加成的 hp_max 去钳,静默吞血。
func get_save_data() -> Dictionary:
return {"hp": hp, "gold": gold, "xp": xp, "level": level}
func load_save_data(data: Dictionary) -> void:
hp = float(data.get("hp", 100.0))
gold = int(data.get("gold", 0))
xp = int(data.get("xp", 0))
level = int(data.get("level", 1))
xp_to_next = xp_for_level(level)
stats_changed.emit()
旧存档里的 hp_max / cpu_limit 键被忽略即可,不需要提升 schema 版本号:hp_max 全项目从无写入方(永远是 100),cpu_limit 从未被读取过。
📌 回填说明:原计划只写了「留给货架 C 的提示」一句话。评审后补了两条会真咬人的顺序/类型陷阱(
assignvs=、_modifiers必须先于hp回读)—— 前者是运行时类型错误,后者是静默吞血:先赋hp再重算,hp = minf(hp, hp_max)会拿"还没加成的 hp_max"去钳一个"已经加成过的 hp"。
- Step 4: 校验语法 + 数据
Run: godot-mcp-pro validate_script on res://scripts/autoloads/player_stats.gd — valid: true(该文件无 class_name,失败即真错误)。
Run: godot-mcp-pro execute_editor_script:
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json"))
var fails := 0
for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]:
if not (d is Dictionary) or not d.has(k): fails += 1
if abs(float(d["move_speed"]["base"]) - 200.0) > 0.001: fails += 1
if String(d["cpu_limit"]["combine"]) != "add_int": fails += 1
if String(d["cast_delay_mod"]["combine"]) != "inverse": fails += 1
var src := FileAccess.get_file_as_string("res://scripts/autoloads/player_stats.gd")
if src.contains("var armor") or src.contains("var resistance"): fails += 1
_mcp_print("FAILS=%d" % fails)
Expected: FAILS=0
- Step 5: 提交
git add data/attributes.json scripts/autoloads/player_stats.gd
git commit -F- <<'EOF'
feat(attr): attributes.json + PlayerStats 属性框架;删孤儿字段 armor/resistance
PlayerStats 只负责加载定义、持有加成列表、把公式结果写进静态类型裸字段,
不含任何公式(公式在 AttributeFormula)。读取端零开销以满足热路径纪律
(move_speed 每物理帧被 player_manager 读)。
armor/resistance 零消费方且不在 numerical_design §1.1 权威属性表内,属实现
先于设计的残留,删除;真要做玩家侧减伤时按货架 C 属性词条立项(见 spec §2.6)。
hp_max/cpu_limit 现为派生值,从存档字段移除——回读会覆盖公式结果。
旧存档相应键忽略即可,无需提升 schema 版本(hp_max 全项目从无写入方、
cpu_limit 从未被读取)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 3: 四处接线
Files:
-
Modify:
scripts/domain/spell_system/spell_evaluator.gd:332 -
Modify:
scripts/domain/player_manager.gd:7,45,81,88 -
Modify:
scripts/domain/combat/combat_manager.gd:267-271 -
Modify:
scenes/main/combat_test.gd(原计划漏了这个消费方) -
Step 1: MAX_OPS 读生效
cpu_limit
spell_evaluator.gd,把 :332 的
var max_ops: int = MAX_OPS_PER_CPU * 5
改为(实际落地版本:只留下限,守卫另置)
# 生效 cpu_limit 已含法杖份额(combat_manager._rebuild_wand 以 source="core" 的加成接入)
# 下限 1:注入缺失时不把游戏打成砖(0 会让 while 一步都不走);该异常由 equip_wand 处报出
var max_ops: int = MAX_OPS_PER_CPU * maxi(PlayerStats.cpu_limit, 1)
📌 回填说明 —— 原写法为何不可用:原计划在这里放
if PlayerStats.cpu_limit <= 0: push_error(...)。评审改为搬到PlayerManager.equip_wand(见 Step 3)。理由两条:① 本函数每秒被调用约 2 次(施法频率),守卫一旦触发就是每秒两条报错刷屏,反而把根因埋掉;equip_wand每次换杖才跑一次。② 这里拿不到杖名,报错说不出"是哪把杖配成 0 的"。守卫要放在故障"变得可观测"且"报一次就够"的地方,不是放在故障"造成后果"的地方。
- Step 2: 移速改读属性
player_manager.gd:删除 :7 整行 const MOVE_SPEED: float = 200.0;把 :45 的
_velocity = dir * MOVE_SPEED
改为
_velocity = dir * PlayerStats.move_speed
- Step 3: 施法间隔乘
cast_delay_mod(在开火时,不在装备时)
player_manager.gd:
:17字段注释改为var _cast_interval: float = 0.5 # 法杖基准间隔(core.cast_interval);实际间隔在开火时 × cast_delay_mod_handle_auto_cast(:81)的_cast_timer重置改为:
_cast_timer -= delta
if _cast_timer <= 0.0:
_cast_timer = _cast_interval * PlayerStats.cast_delay_mod
SpellEvaluator.execute_compiled(_equipped_compiled, 0, _position)
equip_wand(:87)保持只装法杖基准,并在末尾加 0 守卫:
_cast_interval = core.cast_interval if core else 0.5
...
# 0 守卫:cpu_limit=0 → MAX_OPS 退化,法术近乎全静默。放在换杖处而非施法处——施法端 2 次/秒
# 会刷屏埋掉根因,且此处拿得到杖名。兜的是「新调用点忘了注入」与「cores.json 某杖配成 0」,
# 二者上游无任何报错(缺 attributes.json / attr_id 打错已分别由 PlayerStats._load_attr_definitions
# 与 PlayerStats.add_modifier 报出)
if PlayerStats.cpu_limit <= 0:
push_error("PlayerManager: 装备「%s」后 cpu_limit=0,法术执行预算将退化为 40 步;检查该杖 cores.json 的 cpu_limit 与 source=\"core\" 加成注入" % (core.display_name if core else "—"))
📌 回填说明 —— 原写法为何不可用:原计划写
_cast_interval = (core.cast_interval if core else 0.5) * PlayerStats.cast_delay_mod,即在装备时把 mod 折进_cast_interval。这会把 mod 冻结在换杖那一刻:局中任何一次add_modifier("cast_delay_mod", ...)(这正是货架 C 要做的事)在下次换杖之前完全不生效。改到开火时读,mod 立刻生效,且_cast_interval恢复"就是法杖基准"这个干净语义。 代价是 Task 5 原验收脚本(读pm._cast_interval期待它减半)随之失效 —— 见 Task 5 Step 4 的适配。
- Step 4: 法杖
cpu_limit作为加成来源接入
combat_manager.gd 的 _rebuild_wand()(:257),在既有 mana_leech 重算段(:267-271)之后追加:
# 法杖运算力以加成来源接入(非调用点相加),使 hard 上限作用于生效总值
# remove 必须在 add 之前:本函数每次 install_spell 都会跑,漏了 remove 会让 cpu_limit
# 5→10→15 地累积,而 hard: 50 会把它封成一个看起来合理的数字,不是显眼的异常值
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, "core")
📌 回填说明:原计划只写「顺序要紧:先
remove再add」。补充的是为什么这个 bug 特别难发现:_rebuild_wand每次装法术都跑,累积会很快撞上hard: 50被封顶 —— 封顶后它就成了一个"看起来挺合理"的常数,而不是一路涨到显眼的荒谬值。
- Step 4b: 补
combat_test.gd的注入(原计划遗漏)
cpu_limit 玩家基准改为 0 后,任何绕过 CombatManager._rebuild_wand 的场景都会退化。scenes/main/combat_test.gd 正是这样的调试场景:
# 本场景绕过 CombatManager,故须自行注入法杖运算力份额(否则 cpu_limit=0 → MAX_OPS 退化)
# 顺序理由见 combat_manager._rebuild_wand:remove 必须先于 add(否则重复进入会累积),
# 而两者又必须都先于 equip_wand(equip_wand 里的 cpu_limit<=0 守卫读的就是注入结果)
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(loadout["core"].cpu_limit), "core")
PlayerManager.equip_wand(loadout["core"], loadout["compiled"])
📌 回填说明 —— 原计划为何漏了这处:spec §2.5 的接线表只列了三处(evaluator / combat_manager / player_manager),是按"谁读属性"梳理的;但基准值从 5 改成 0 这个决定,把**"谁负责注入"变成了一条新的全局不变式,而它的破坏者是不读属性的**调用方。改基准值时要问的不是"谁读它",而是"谁绕过了它的注入点"。
- Step 5: 校验语法
Run: godot-mcp-pro validate_script on res://scripts/domain/spell_system/spell_evaluator.gd、res://scripts/domain/player_manager.gd、res://scripts/domain/combat/combat_manager.gd、res://scenes/main/combat_test.gd — 均 valid: true。
- Step 6: 提交
git add scripts/domain/spell_system/spell_evaluator.gd scripts/domain/player_manager.gd scripts/domain/combat/combat_manager.gd scenes/main/combat_test.gd
git commit -F- <<'EOF'
feat(attr): 接线 MAX_OPS/移速/施法间隔;法杖 cpu_limit 以加成来源接入
MAX_OPS 由硬编码 40×5 改为读生效 cpu_limit——cores.json 里逐法杖配置的
3/5/6/8 自此生效,法杖间恢复运算力区分度。玩家基准取 0,故小木法杖仍为
5 → 200 步,现有平衡零改动。
法杖份额走 add_modifier 而非调用点相加:否则 hard(50) 只钳制玩家那一份,
法杖份额加在钳制之后可使总值越界。换杖时先 remove_modifiers_from("core")
再 add,防止累积。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
(实际分了三个提交:a3bb778 主接线、6f11f4c 补 combat_test + 记时序、729c749 守卫移位 + cast_delay_mod 改开火时生效。)
Task 4: 设计器「属性」页
Files:
- Create:
addons/game_designer/attribute_tab.gd(+.gd.uid) - Modify:
addons/game_designer/designer_panel.gd(_init()里_add_tab注册)
按 CLAUDE.md「新增分页 3 步」:
@tool extends VBoxContainer标签脚本(复用designer_ui.gd)→designer_panel.gd的_init()里_add_tab(...)→ 对应加载。控件一律经UI.spin/opt/btn/header/status_label/load_json/save_json创建,不要自建裸控件。标签「中文 English」双语序。combine下拉存 key 不存显示串。不要做热重载(Task 2 评审前瞻提醒):本页只编辑并保存 JSON,不尝试让运行中的
PlayerStats立即生效 —— 与其它设计器页一致(保存后提示「重启 F5 生效」)。若日后真要加热重载,注意remove_modifiers_from不是强制重新同步的原语(无命中时提前返回),必须自己调_load_attr_definitions()+_recompute_attrs()。
inverse的hard语义与其它两种相反(Task 1 实现者发现):hybrid/add_int下hard=0表示「不钳制」,而inverse下hard是下限、0表示「钳到 0」。设计器里编辑hard时应在提示文案中体现这个差异,否则设计师给inverse属性填 0 期待「不限制」会得到相反结果。
- Step 1: 实现
attribute_tab.gd
签名已核对自 designer_ui.gd:UI.spin(min, max, step, val) -> SpinBox、UI.opt(items: Array, selected: int) -> OptionButton、UI.header(text)、UI.cell_label(text)、UI.btn(text, cb)、UI.status_label()、UI.set_status(lbl, msg, is_err)、UI.load_json(path)、UI.save_json(path, data) -> bool。结构照 balance_tab.gd。
Create addons/game_designer/attribute_tab.gd(以下为实际落地版本):
## 属性编辑器标签 — 玩家属性的 base / soft / hard / combine
## 数据:res://data/attributes.json(权威属性定义见 docs/design/numerical_design.md §1.1)
@tool
extends VBoxContainer
const UI = preload("res://addons/game_designer/designer_ui.gd")
const PATH = "res://data/attributes.json"
# 显示与存储解耦:LABELS 仅供下拉显示,KEYS 才写进 JSON,二者 index 对齐
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"]
var _data: Dictionary = {}
var _rows: Dictionary = {} # attr_id → {"base": SpinBox, "soft": SpinBox, "hard": SpinBox, "combine": OptionButton}
var _status: Label
var _loaded: bool = false # 加载失败时禁止写回,见 _save
func _ready() -> void:
add_child(UI.header("📊 属性编辑器 — 玩家属性 base / soft / hard / combine"))
_load()
var grid := GridContainer.new(); grid.columns = 5; add_child(grid)
for h in ["属性", "基准 base", "软上限 soft", "硬上限 hard", "合成 combine"]:
var l := Label.new(); l.text = h; l.modulate = Color(0.7, 0.8, 1.0)
grid.add_child(l)
for attr_id in ATTR_ORDER:
# _load 已保证:_loaded 时每个条目都是 Dictionary,否则 _data 为空
var d: Dictionary = _data.get(attr_id, {})
grid.add_child(UI.cell_label(String(d.get("display_name", attr_id))))
# 四个属性按定义均非负,故 min = 0;且 min 取 0 可避开 Range 的抵消误差(见 _clean)
var sp_base := UI.spin(0.0, 99999.0, 0.01, float(d.get("base", 0.0)))
var sp_soft := UI.spin(0.0, 99999.0, 0.01, float(d.get("soft", 0.0)))
var sp_hard := UI.spin(0.0, 99999.0, 0.01, float(d.get("hard", 0.0)))
var combine_key := String(d.get("combine", "hybrid"))
var idx: int = COMBINE_KEYS.find(combine_key)
# 本页凡是「静默改写权威数据」的路径都必须留下诊断,一条都不能漏——
# 保存会把改写结果写回 attributes.json,而下游 PlayerStats 只会照单全收
if d.is_empty():
# 条目缺失时占位的 0 一旦被保存,键就「存在」了,
# player_stats.gd 的「缺少属性」报错从此不再触发,hp_max=0 静默进游戏
push_warning("attribute_tab: %s 缺少属性「%s」,本页以 0 值占位,保存会把 0 写进文件" % [PATH, attr_id])
else:
if idx < 0:
# 不硬拒(打字错误不该让整页打不开),但必须留下诊断:保存会把它改写成 hybrid
push_warning("attribute_tab: 「%s」的 combine「%s」无法识别,下拉已回落到 hybrid,保存将覆盖原值" % [attr_id, combine_key])
for f in ["base", "soft", "hard"]:
if d.has(f) and float(d[f]) < 0.0:
push_warning("attribute_tab: 「%s」的 %s = %s 为负,输入框下限 0 已将其钳制,保存会把 0 写进文件" % [attr_id, f, str(d[f])])
var op := UI.opt(COMBINE_LABELS, idx if idx >= 0 else 0)
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())
var tip := Label.new()
tip.text = "hard=0 在 hybrid / add_int 下表示不钳制;inverse 的 hard 是「下限」,填 0 即钳到 0(与 hybrid 相反);add_int 拒绝 pct 加成;soft 本期不参与计算(留给货架 C)。"
# dock 的 ScrollContainer 禁用横向滚动,不换行的话这句会被右侧裁掉(正是最需要看到的半句)
tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
tip.modulate = Color(0.7, 0.7, 0.7)
add_child(tip)
add_child(UI.btn("💾 保存 attributes.json", _save))
_status = UI.status_label(); add_child(_status)
## 加载失败必须留痕并禁止写回:本页的零值载荷(move_speed.base = 0 等)是合法 JSON,
## PlayerStats 会照单全收,游戏表现为玩家不能移动 / 瞬间死亡且毫无诊断。
func _load() -> void:
var d = UI.load_json(PATH)
_loaded = d is Dictionary
if not _loaded:
push_error("attribute_tab: 无法加载 %s(缺失或格式错误)" % PATH)
_data = {}
return
# 逐条守卫,同 player_stats.gd 的既有做法:畸形条目(如 "hp_max": 5)会让下面的
# Dictionary 赋值硬崩、dock 停在半构建状态;整个文件拒绝加载并禁止写回
for k in d:
if not (d[k] is Dictionary):
push_error("attribute_tab: %s 的「%s」应为对象,整个文件已拒绝加载" % [PATH, k])
_loaded = false
_data = {}
return
_data = d
## Range 用 round((v - min) / step) * step + min 吸附取值。min = -99999 与 0.01 的 step
## 相差七个数量级,此式在此发生抵消误差(0.01 → 0.00999999999476),直接写回会把噪声
## 固化进 JSON。本页 min = 0.0 时 12 个值原本就逐位精确,本函数当前不改变落盘结果
##(唯一副作用:0.1 在内存里低 1 ULP,stringify 仍输出 0.1)。
## 勿因此删除 —— 一旦某属性需要负值 / 宽区间而放宽 min,抵消误差立即回来。
static func _clean(v: float) -> float:
return snappedf(v, 0.000001)
## 表单 → 字典(合并式:以 _data 为基底保留未知属性条目,以及条目内未列入表单的键;
## 顶层标量键不在此列——_load 已因它们整体拒绝加载)
func _collect() -> Dictionary:
var out: Dictionary = _data.duplicate(true)
for attr_id in ATTR_ORDER:
var r: Dictionary = _rows.get(attr_id, {})
if r.is_empty():
continue
var entry: Dictionary = out.get(attr_id, {}).duplicate(true)
entry["base"] = _clean(r["base"].value)
entry["soft"] = _clean(r["soft"].value)
entry["hard"] = _clean(r["hard"].value)
# 存 key,不存双语显示串;selected 恒为 0..2(UI.opt 构造时已 clampi,本页此后不再赋值)
entry["combine"] = COMBINE_KEYS[r["combine"].selected]
out[attr_id] = entry
return out
func _save() -> void:
if not _loaded:
UI.set_status(_status, "✗ 定义未加载,拒绝写回", true)
return
if UI.save_json(PATH, _collect()):
UI.set_status(_status, "💾 已保存(重启 F5 生效)")
else:
UI.set_status(_status, "✗ 保存失败", true)
为什么 UI 建在
_ready()而非_init():@tool面板的控件在节点未入树时操作会出错(项目已踩过:text_edit.cpp索引越界)。七个既有分页都为此从_init改到了_ready,勿重蹈。 为什么_collect()以_data.duplicate(true)为基底:防丢键。balance_tab._save曾因新建空字典而丢掉mana_heat/infinite_spells,已修,勿重犯。📌 回填说明 —— 原写法为何不可用(五处,均为 Task 4 两轮评审所改):
UI.spin的min由-99999.0收紧为0.0(+_clean)—— 这条最重要,会真的改坏权威数据:Range用round((v - min) / step) * step + min吸附取值;min = -99999与step = 0.01相差七个数量级,此式发生抵消误差。实测:0.1 → 0.100000000005821、0.01 → 0.00999999999476131,且这两个值会被JSON.stringify原样写进文件。任何人打开一次属性页再按保存,attributes.json就被污染。_loaded守卫 +_save拒绝写回(新增):加载失败时表单是一片 0,而一片 0 是合法 JSON。保存下去move_speed.base = 0(玩家不能动)、hp_max = 0(进场即死),且PlayerStats照单全收、零诊断。_load的逐条is Dictionary守卫(新增):畸形条目会让var d: Dictionary = _data.get(...)硬崩,dock 停在半构建状态。- 提示语加
autowrap_mode(新增):dock 的ScrollContainer禁用横向滚动,不换行时这句会被右侧裁掉 —— 被裁掉的正是inverse的 hard 是下限那半句,即最需要被看到的内容。- 两条
push_warning(新增):本页有两条「静默改写权威数据」的路径 —— 条目缺失时以 0 占位(保存后这个键就"存在"了,player_stats.gd的"缺少属性"报错从此永不触发),以及combine无法识别时回落hybrid(保存即覆盖原值)。凡静默改写必须留诊断。
- Step 2: 在
designer_panel.gd注册分页
在 _init() 的 _add_tab 序列末尾(现 :19 的「⚖ 平衡」之后)追加:
_add_tab(tabs, "📊 属性", preload("res://addons/game_designer/attribute_tab.gd").new())
- Step 3: 校验 + 往返实测(逐位精确,非容差)
Run: godot-mcp-pro validate_script on res://addons/game_designer/attribute_tab.gd 与 res://addons/game_designer/designer_panel.gd — 均 valid: true(两者均无 class_name,失败即真错误)。
Run: godot-mcp-pro execute_editor_script(用绕缓存法真实实例化并驱动真实控件,不要只 grep 源码文本 —— 那不是往返):
var S := GDScript.new()
S.source_code = FileAccess.get_file_as_string("res://addons/game_designer/attribute_tab.gd")
if S.reload() != OK:
_mcp_print("PARSE_FAIL"); return
var tab = S.new()
tab._ready() # 节点不入树也能建出真实 SpinBox/OptionButton
var orig = JSON.parse_string(FileAccess.get_file_as_string("res://data/attributes.json"))
var out: Dictionary = tab._collect()
var fails := 0
var bad := []
for k in ["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]:
for f in ["base", "soft", "hard"]:
# ⚠️ 逐位精确,不用容差。往返要防的量化漂移量级是 ~5e-12,
# 任何 1e-4 量级的容差都看不见它——见下方「原写法为何不可用」。
if JSON.stringify(float(orig[k][f])) != JSON.stringify(float(out[k][f])):
fails += 1
bad.append("%s.%s %s→%s" % [k, f, JSON.stringify(orig[k][f]), JSON.stringify(out[k][f])])
if String(orig[k]["combine"]) != String(out[k]["combine"]):
fails += 1; bad.append("%s.combine %s→%s" % [k, orig[k]["combine"], out[k]["combine"]])
# combine 必须存 key 而非双语显示串
if String(out["cpu_limit"]["combine"]) != "add_int": fails += 1; bad.append("combine 存了显示串")
# display_name 等未列入表单的键不得丢失
if not out["cpu_limit"].has("display_name"): fails += 1; bad.append("丢键 display_name")
# 终局判据:落盘文本逐字节一致(这才是真正会被写进文件的东西)
if JSON.stringify(out, " ", true) != JSON.stringify(orig, " ", true):
fails += 1; bad.append("序列化文本不一致")
tab.free()
_mcp_print("ROUNDTRIP_FAILS=%d %s" % [fails, str(bad)])
Expected(实测 2026-07-31):ROUNDTRIP_FAILS=0 []
📌 回填说明 —— 原计划这里写了一句假话,且断言够不着它
原注释:「本期四个属性的 base/soft/hard 全部落在 0.01 格点上,故往返无量化损失。」这句是错的,而配套的
abs(a - b) > 0.0001容差断言在原理上就不可能发现它。Task 5 实测(构造
SpinBox直接量):
值 min = -99999(评审前)min = 0.0(已上线)0.10.100000000005821❌0.1✅0.010.00999999999476131❌0.01✅其余 9 个 逐位精确 逐位精确 两处漂移的值
0.1和0.01恰恰都在 0.01 格点上 —— 所以"落在格点上"根本不是无损的理由。真正的成因是Range的吸附式round((v - min) / step) * step + min在min与step相差七个数量级时发生抵消误差,与值是否对齐格点无关。而漂移量级是
~5e-12,比0.0001容差小七个数量级:这个断言即使在有 bug 的版本上跑也是绿的。容差断言只有在你知道误差上界时才有意义;用来守"应该完全没有误差"的不变式时,它守的是空气。 现在改为对JSON.stringify的输出做逐字节比较 —— 那正是会被写进文件的东西,也是唯一有意义的判据。(附注:
_clean的snappedf会把0.1在内存里挪低 1 ULP,但stringify仍输出0.1,故落盘不变。这也说明"内存逐位相等"不是正确的判据,"落盘文本相等"才是。)
- Step 4: 提交
git add addons/game_designer/attribute_tab.gd addons/game_designer/attribute_tab.gd.uid addons/game_designer/designer_panel.gd
git commit -F- <<'EOF'
feat(attr): 游戏设计器新增「属性」页,字段化编辑 attributes.json
按 CLAUDE.md 编辑器插件规范:控件经 designer_ui 工厂创建、标签中英双语中文在前、
combine 下拉存 key 不存显示串、_save 合并式写回防丢键、UI 在 _ready 而非 _init 构建。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
(实际分了三个提交:a0e205a 主体、36c4f79 自动换行 + 拒绝写回 + 收紧 SpinBox 下限、7884e43 补最后两条诊断。)
Task 5: 运行时验收 + 收尾
Files: 无(运行时断言)+ docs/design/numerical_design.md + 路线图 + 本计划
- Step 1: 启动战斗场景
Run: godot-mcp-pro play_scene → res://scenes/main/combat_s2.tscn。
实测:开局即自动进入第 1 波,HUD 正常(HP 100/100、小木法杯、action_spark_bolt),get_editor_errors 0 条。运行时状态快照:
cpu_limit=5 move_speed=200.00 cast_delay_mod=1.0000 hp_max=100.0 hp=100.0
modifiers=["cpu_limit/flat/5.0/core"]
attr_def_keys=["cpu_limit", "move_speed", "cast_delay_mod", "hp_max"]
MAX_OPS=200 (MAX_OPS_PER_CPU=40)
- Step 1b: ⚠️ 先建立可信的运行时错误通道(否则后面所有"零报错"断言都是空的)
这一步是 Task 5 新增的,且必须在 Step 7b 之前做。 工具坑 ⑧:运行中游戏的 push_error / push_warning 不会出现在 get_output_log 或 get_editor_errors 里(只有运行时 print() 会)。实测对照:
| 来源 | print() |
push_error() |
push_warning() |
|---|---|---|---|
| 运行中的游戏 | ✅ 进 output 面板 | ❌ 两个通道都不进 | ❌ 两个通道都不进 |
| 编辑器脚本 | ✅ | ✅ | ✅ |
真正的运行时错误通道是编辑器 Debugger →「错误」页的 Tree。读法(execute_editor_script):
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)
该 Tree 每次 play_scene 会重置,故天然按会话隔离。
为什么这步不能省:Step 7b① 要证明的是"守卫零误报"。若报错通道本身是死的,"日志里没有
cpu_limit=0"和"守卫误报了但你看不见"产生完全相同的观测。这正是这个功能已经栽过三次的那类"太松以致守不住东西"的断言。
- Step 2:
cpu_limit生效 + 现有平衡零改动(验收 7、8、9)
② -a 状态断言(execute_game_script):
var fails := 0
var log := []
for pair in [["wand_basic", 5], ["wand_fast", 3], ["matrix_board", 8]]:
var core = WandPreset.make_core_by_id(pair[0])
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", float(core.cpu_limit), "core")
var eff: int = PlayerStats.cpu_limit
if eff != int(pair[1]):
fails += 1; log.append("%s eff=%d want=%d" % [pair[0], eff, pair[1]])
log.append("%s cpu=%d MAX_OPS=%d" % [pair[0], eff, SpellEvaluator.MAX_OPS_PER_CPU * eff])
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core")
PlayerStats.remove_modifiers_from("core")
PlayerStats.add_modifier("cpu_limit", "flat", 8.0, "core")
if PlayerStats.cpu_limit != 8:
fails += 1; log.append("换杖累积 got=%d want=8" % PlayerStats.cpu_limit)
_mcp_print("FAILS=%d %s" % [fails, str(log)])
实测:FAILS=0 ["wand_basic cpu=5 MAX_OPS=200", "wand_fast cpu=3 MAX_OPS=120", "matrix_board cpu=8 MAX_OPS=320", "换杖不累积 5→8 得 8 (mods=1 条)"]
② -b 端到端断言(必做 —— ②-a 是同义反复):②-a 里的 MAX_OPS_PER_CPU * eff 是把被测公式在断言里重算一遍,它只证明了 PlayerStats.cpu_limit 对,没证明 execute_compiled 真的用了它。真正的可观测量是 spell_evaluator.gd:370 的 if ops_count >= max_ops: push_warning("SpellEvaluator: MAX_OPS reached")。
做法:编一副 N 个 modifier_spread_mod(mana_cost 0,且 MODIFIER 不消耗 actions_remaining,故能一路跑到 max_ops)的 LINEAR 牌组,跨越候选 max_ops 值两侧施法,看告警是否按预期出现:
| 探针 | cpu_limit |
理论 max_ops |
牌组长度 | 预期 | 实测 |
|---|---|---|---|---|---|
| A | 3 | 120 | 150 | 触发 | ✅ 触发(0:03:31:012) |
| B | 5 | 200 | 150 | 不触发 | ✅ 未触发 |
| C | 8 | 320 | 150 | 不触发 | ✅ 未触发 |
| D | 8 | 320 | 250 | 不触发 | ✅ 未触发 |
| E | 5 | 200 | 250 | 触发 | ✅ 触发(0:06:01:709) |
按 Step 1b 的读法统计:MAX_OPS_reached 次数=2 时间戳=["0:03:31:012", "0:06:01:709"] —— 恰好是 A 和 E。这把 max_ops 夹逼为:cpu=3 → <150、cpu=5 → (150, 250]、cpu=8 → >250,与 120 / 200 / 320 一致。验收 7、8、9 由此变成真实观测而非同义反复。
② -c 真实 UI 换杖路径:在商店点「法杖」按钮轮换全部 5 把杖(按钮文案依次变为 高速法杯 / 记忆法杖 / 矩阵板 / 分叉回路)。抽样:wand_basic→wand_fast 后 cpu_limit 由 5 变 3(而非累加成 8),matrix_board 时为 8,_modifiers 全程恒为 1 条。验收 9 走的是真实 UI 路径而非合成调用。
📌 回填说明:原计划只有 ②-a。评审期没人注意到它是同义反复 —— 断言把
MAX_OPS_PER_CPU * cpu_limit重算了一遍,而这正是被测代码本身。②-b 才是端到端。
- Step 3:
move_speed数据驱动(验收 10)
③ -a 数值断言(execute_game_script):
PlayerStats.remove_modifiers_from("test")
# base=200 → +50%pct=300 → 再 +100flat=450 → 再 +200%pct 应被 hard(800) 钳住 → 撤销回 200
实测:FAILS=0 ["base=200.00", "+50%pct=300.00", "+100flat=450.00", "再+200%pct(应钳到800)=800.00", "撤销后=200.00"]
(比原计划多一条:hard: 800 的上限钳制在运行时也验过,(200+100)×1.5×3 = 1350 → 800。)
③ -b 物理位移实测(原计划没有,但验收 10 的字面要求是"玩家实际移速跟随变化"):execute_game_script 不支持 await(工具坑 ⑨),故分两次调用:先 Input.action_press("ui_right") 并把位置/物理帧号存进 Engine.set_meta,隔约 7 秒再 action_release 并算 Δ位移 / (帧数 × 1/60)。
-
无加成:
实测=200.00 px/s(Δ=1503.34 px / 451 帧) -
+50% pct:实测=300.00 px/s(Δ=1665.00 px / 333 帧) -
Step 4:
cast_delay_mod生效(验收 11)—— 原脚本已失效,此为适配版
📌 回填说明 —— 原脚本为何跑不出正确结论:原 Step 4 读
pm._cast_interval并期待它等于0.25。Task 3 评审把cast_delay_mod从装备时折叠改到了开火时相乘(见 Task 3 Step 3),_cast_interval现在只装法杖基准0.5。原断言会失败,但失败的是断言不是实现 —— 而且更糟的是:如果有人为了让它变绿而把实现改回装备时折叠,就把货架 C 的可用性一并改没了。
var core = WandPreset.make_core_by_id("wand_basic") # cast_interval 0.5
var comp = SpellEvaluator.compile_wand(core, [SpellRegistry.get_spell("action_spark_bolt")])
PlayerStats.remove_modifiers_from("test")
PlayerManager.equip_wand(core, comp)
# ① 装备时 _cast_interval 只装法杖基准,不含 mod
assert(abs(PlayerManager._cast_interval - 0.5) < 0.0001)
PlayerStats.add_modifier("cast_delay_mod", "flat", -0.5, "test") # inverse: 1.0 + (-0.5) = 0.5
# ② _cast_interval 不随加成变动(证明 mod 没有被折进去 —— 局中加成必须能生效)
assert(abs(PlayerManager._cast_interval - 0.5) < 0.0001)
# ③ 生效间隔 = 基准 × mod = 0.25(减半)
# ④ 开火时写进 _cast_timer 的就是生效间隔
PlayerManager._cast_timer = 0.0
PlayerManager._handle_auto_cast(0.001)
assert(abs(PlayerManager._cast_timer - 0.25) < 0.0001)
PlayerStats.remove_modifiers_from("test")
实测:FAILS=0 ["_cast_interval=0.5000(应=core.cast_interval 0.5,不含 mod)", "cast_delay_mod=0.5000", "加成后 _cast_interval 仍=0.5000", "生效间隔=0.2500(应 0.25,减半)", "开火后 _cast_timer=0.2500(应 0.25)"]
另有 Step 7b② 的扫描作为端到端佐证:wand_basic 在 mod=1.0 时实测帧距 31 帧,mod=0.5 时 16 帧 —— 实际施法周期确实减半(差 1 帧是整帧量化,见 7b②)。
- Step 5: 多来源互不干扰(验收 12)
PlayerStats.add_modifier("cpu_limit", "flat", 5.0, "core")
PlayerStats.add_modifier("cpu_limit", "flat", 3.0, "shopC") # both = 8
PlayerStats.remove_modifiers_from("core") # only_shop = 3
PlayerStats.remove_modifiers_from("shopC") # none = 0
# 加强:同一 source 对两个属性各加一条,撤销须两条一起走、且不碰他源
PlayerStats.add_modifier("cpu_limit", "flat", 4.0, "shopC")
PlayerStats.add_modifier("move_speed", "flat", 50.0, "shopC")
PlayerStats.add_modifier("move_speed", "flat", 30.0, "other")
PlayerStats.remove_modifiers_from("shopC") # 应得 cpu=0, spd=230
实测:FAILS=0 both=8 only_shop=3 none=0 | 跨属性同源: cpu=4 spd=280.0 → 撤 shopC → cpu=0 spd=230.0(应 cpu=0 spd=230)
- Step 5b: 评审改动的运行时回归(原计划没有,补四条)
非法加成后 _modifiers 条数 1→1(应不变) ← add_modifier 校验
hp_max 200→100 后 hp 由 200 被钳到 100(非停在 200) ← _recompute_attrs 的 minf
两条 -200% pct → move_speed=0.00(非负负得正回到 200) ← AttributeFormula 越界守卫
reset_for_run 后 mods=0 hp_max=100.0 hp=100.0 ← 清 _modifiers + 重算早于 hp=hp_max
get_save_data 键=["hp", "gold", "xp", "level"] ← 派生值已出存档
实测:FAILS=0,且 Debugger 错误页同步出现三种预期诊断共 5 条(未知属性「zzz_bogus_attr」×1 / 未知 mode「zzz_bogus_mode」×1 / pct 越界(value=-2.000000 → 因子 -1.000000)×3 —— 3 条是因为第二条 pct 加入时 _recompute_attrs 会把两条一起重算)。
- Step 6: 孤儿字段零残留 + 存档往返(验收 13)
Run: godot-mcp-pro stop_scene,然后 execute_editor_script 扫四个文件的 PlayerStats.armor / PlayerStats.resistance / var armor / var resistance / MOVE_SPEED,外加 get_save_data/load_save_data 函数体内不得提及 hp_max / cpu_limit。
实测:FAILS=0 []
全项目 grep 复核:armor / resistance 在 scripts/ 下只剩敌人侧用法 —— damage_context.calc_damage(armor, resistance) 的形参、enemy_manager 的 _ARMOR / resistances、设计器 enemy_tab。玩家侧零残留,敌人 armor 与元素抗性是另一套,不受本次影响。
-
Step 7: 订正权威文档 + 路线图
-
docs/design/numerical_design.md§1.1move_speed行:基准 300 → 200,并注明「原写 300 从未被任何代码读取过,是纸面孤值;200 自 S0 沿用,20 波内容/Boss 弹幕密度/无敌帧 0.5 s 均按此值调校,以既成事实为准」,引 spec §2.2。 -
同表
cpu_limit行:删掉⚠️实现现状(2026-07-20):代码硬编码 MAX_OPS = 40×5 = 200,从不读 cpu_limit(待修),改为 ✅ 已修复;并把基准值也由 5 改为 0(否则重蹈move_speed那种"纸面值与实现长期脱节"),注明生效值 = 玩家(0) + 法杖(cores.json3–8) 且hard(50) 作用于加总后的总值。 -
docs_dev/plans/2026-07-23-missing-features-roadmap.md:E3 切分 ① 勾除并标注实际范围(只做框架 + 三条死数据;attunement_×4/luck/recharge_speed_mod各附延后理由;mana_max的 Min 语义记为遗留);订正 E3「现状」里「player_stats.gd有resistance等占位属性(4/5 stats 未接线)」这句(权威属性表是 11 个,armor/resistance根本不在表内);E1 现状里同源的「玩家侧抗性仍占位」一句一并订正。
提交:1aeae19
- Step 7b: 守卫零误报 +
cast_delay_mod饱和点(Task 3 复审转来)
① 守卫零误报 —— 实测结论:零误报,且通道已证明是活的。
先决条件是 Step 1b(否则观测为空)。观测结果:
| 入口路径 | 观测 | 结果 |
|---|---|---|
start_game(play_scene 开局) |
Debugger 错误页 | 0 条 cpu_limit=0,cpu_limit=5 |
| 商店「法杖」按钮 ×4(轮换覆盖全部 5 把杖) | 同上 | 0 条;抽样点 wand_basic=5、wand_fast=3、matrix_board=8,_modifiers 恒 1 条(wand_memory/circuit_fork 两次未逐个抽样 cpu_limit 值,但同样 0 条报错——守卫若被触发会留痕) |
combat_test.tscn(独立会话) |
同上 | 0 条,cpu_limit=5 |
正对照:手动 remove_modifiers_from("core") 后调 equip_wand |
同上 | ✅ 报了 1 条 player_manager.gd:98 @ equip_wand(): PlayerManager: 装备「小木法杯」后 cpu_limit=0… |
正对照是这一步的关键:它证明守卫是活的,因此"其余路径 0 条"才有信息量。整场会话(约 13 分钟)的运行时报错逐条清点后全部能归因到本次的故意探针,无一条意外。
② cast_delay_mod 的 hard: 0.01 承诺了循环交付不了的东西 —— 设计决策,仅上报数字,不在代码里解决。
_handle_auto_cast 用 if 而非 while,每物理帧至多一次施法,60 Hz 物理步长下硬顶 60 次/秒。且 :81 是赋值而非累积扣减,亚帧余量每周期被丢弃,故实际速率按整帧量化。
测法(工具坑 ⑨/⑩):向 get_tree().root 挂一个临时 _physics_process 节点,按相位自动切换法杖与 cast_delay_mod(用 flat 模式精确取值:inverse 下 mod = 1.0 + flat),每相位 180 帧,记录相邻两次施法的帧距分布。每个相位的帧距分布都是单值(如「1 帧×179」「2 帧×89」),这本身就是量化的直接证据。
| 法杖 | cast_delay_mod |
生效间隔 T | 实测帧距 | 实际速率 |
|---|---|---|---|---|
wand_basic(基准 0.5 s) |
1.0 | 0.5 s | 31 帧 | 1.94 /s |
| 0.5 | 0.25 s | 16 帧 | 3.75 /s | |
| 0.1(soft) | 0.05 s | 3 帧 | 20 /s | |
| 0.07 | 0.035 s | 3 帧 | 20 /s | |
| 0.06 | 0.03 s | 2 帧 | 30 /s | |
| 0.04 | 0.02 s | 2 帧 | 30 /s | |
| 0.034 | 0.017 s | 2 帧 | 30 /s | |
| 0.033 | 0.0165 s | 1 帧 | 60 /s ← 饱和点 | |
| 0.02 | 0.01 s | 1 帧 | 60 /s | |
| 0.01(hard) | 0.005 s | 1 帧 | 60 /s | |
wand_fast(基准 0.25 s) |
1.0 | 0.25 s | 16 帧 | 3.75 /s |
| 0.1(soft) | 0.025 s | 2 帧 | 30 /s | |
| 0.07 | 0.0175 s | 2 帧 | 30 /s | |
| 0.0667 | 0.016675 s | 2 帧 | 30 /s | |
| 0.05 | 0.0125 s | 1 帧 | 60 /s ← 饱和点 | |
| 0.01(hard) | 0.0025 s | 1 帧 | 60 /s |
结论(供决策):
- 饱和点 =
(1/60) / 法杖基准间隔。wand_basic≈ 0.0333、wand_fast≈ 0.0667、circuit_fork(0.6 s) ≈ 0.0278。 - 从饱和点到
hard(0.01) 之间买不到任何东西:wand_basic白费 3.3 倍区间,wand_fast白费 6.7 倍。 - soft→hard 整段只有 3 个可达档位(
wand_basic:20 / 30 / 60 次每秒),wand_fast只有 2 个(30 / 60)—— 快端是台阶不是平滑曲线。 - 帧距总是
ceil(T × 60),且T恰为整帧倍数时会多一帧(T=0.5得 31 而非 30,T=0.25得 16 而非 15):_cast_timer是赋值,浮点余量使它多跑一帧。故实际速率永远 ≤ 名义速率,不会超发。
两个选项,需人定:
- (a) 保持
hard: 0.01,在numerical_design.md与attributes.json注释里写明「低于(1/60)/基准间隔为饱和区,不再提升实际射速」; - (b) 把
hard提到 ~0.05(约等于最慢法杖的饱和点),让 JSON 承诺的是真能交付的东西。
不要在代码里加下限钳制 —— 下限已由 attributes.json + AttributeFormula 的 maxf(hard, ...) 强制,在 player_manager 再加一个会形成重复权威,设计师调 hard 时立刻脱同步,违反「纯数据驱动,代码不含硬编码副本或回退」。本步只上报数字,未改 hard、未加钳制。
③ 顺带收两条 Task 3 复审的 Minor(提交 9f71108):
-
player_manager.gd守卫注释里的player_stats.gd:173/191行号引用会腐烂,改为函数名PlayerStats._load_attr_definitions/PlayerStats.add_modifier。 -
scenes/main/combat_test.gd的注入补上顺序理由(remove先于add防累积;两者都先于equip_wand,因守卫读的正是注入结果)。 -
Step 8: 回填本计划为实际执行版
已完成(即本文件)。回填清单:Task 1 Step 2/3/3b、Task 2 Step 2/3、Task 3 Step 1/3/4/4b(新增)、Task 4 Step 1/3、Task 5 全部(新增 Step 1b/2b/2c/3b/5b)。每处附「原写法为何不可用」。
回填出的一条通用教训:本期有三条断言"太松以致守不住它名义上守的东西"——
-
Task 1 Step 3 的断言 ④(
add_int拒pct):数字部分删掉守卫也照样绿。 -
Task 4 Step 3 的
0.0001容差:比要防的漂移大七个数量级。 -
Task 5 Step 2 的
MAX_OPS_PER_CPU * eff:把被测公式在断言里重算了一遍。 共同点是断言复述了实现,而非观测实现的后果。判据:把被测代码删掉,这条断言会变红吗? 三条的答案都是"不会"。 -
Step 9: 合并决策
用 superpowers:finishing-a-development-branch 决定分支去向。前四个已完成子项(i-frames / 抗性 / 弹跳 / 归航)均合并进 master 并删分支。
验收标准回溯(spec §4)
spec §4 的编号有笔误(出现两个
12),下表以本计划的编号为准:12 =remove_modifiers_from、13 = 孤儿字段/存档、14 = 设计器往返。
| # | 验收标准 | 覆盖 | 观测方式 | 结果 |
|---|---|---|---|---|
| 1 | hybrid 连乘 ×1.728 非 ×1.6 |
Task 1 Step 3 ① | 纯函数断言 | ✅ |
| 2 | hybrid 混合 (200+50)×1.2=300 |
Task 1 Step 3 ② | 纯函数断言 | ✅ |
| 3 | inverse 0.512 + 下限钳制 |
Task 1 Step 3 ③③b | 纯函数断言 | ✅ |
| 4 | add_int 拒 pct,flat 仍累加 |
Task 1 Step 3 ④ + Step 3b | 数字断言不够,靠 push_error 探针 |
✅ |
| 5 | hard 两方向 + hard=0 不钳制 |
Task 1 Step 3 ⑤⑤b⑥c | 纯函数断言 | ✅ |
| 6 | 空列表 → base;未知 combine 回退 |
Task 1 Step 3 ⑥⑥b + 3b | 纯函数断言 + 探针 | ✅ |
| — | (新增)pct 越界不得负负得正 |
Task 1 Step 3 ⑦⑦b + 3b | 纯函数断言 + 探针 | ✅ |
| 7 | cores.json cpu_limit 生效 |
Task 5 Step 2b | 运行时 MAX_OPS reached 告警五点夹逼 |
✅ |
| 8 | 现有平衡零改动(小木仍 200 步) | Task 5 Step 1 / 2b | 开局实测 + 夹逼 | ✅ |
| 9 | 换杖不累积(5→8 得 8 非 13) | Task 5 Step 2a / 2c | 商店按钮真实换杖 | ✅ |
| 10 | move_speed 数据驱动 + 加成生效 |
Task 5 Step 3a / 3b | 数值断言 + 物理位移实测 | ✅ |
| 11 | cast_delay_mod 使间隔减半 |
Task 5 Step 4(已适配) | _cast_timer + 施法帧距扫描 |
✅ |
| 12 | remove_modifiers_from 不误删他源 |
Task 5 Step 5 | 运行时 | ✅ |
| 13 | 孤儿字段零残留 + 存档不含派生值 | Task 5 Step 6 | 源码断言 + 全项目 grep | ✅ |
| 14 | 设计器往返一致 + combine 存 key |
Task 4 Step 3 | 绕缓存实例化 + 落盘文本逐字节比较 | ✅ |
| spec 14 | 解析通过;文件缺失 push_error 不静默回退 |
Task 1–4 各 Step | 解析已由全流程运行证明;「文件缺失 push_error」仅经代码审读,未在运行时构造缺文件场景 |
⚠️ 部分 |