Merge codex/mengsan-lifecycle-fixes into main

This commit is contained in:
2026-10-13 10:44:42 +08:00
12 changed files with 1050 additions and 21 deletions
+9 -2
View File
@@ -15,6 +15,8 @@ tools/*.mjs
/Slay the Spire 2
*.mjs
!tools/test_skill_teardown.mjs
!tools/test_battle_lifecycle.mjs
*.cjs
!mengsan/tools/check-sts2-relics.mjs
!mengsan/tools/write-sts2-relics-report.mjs
@@ -43,6 +45,11 @@ __pycache__/
Thumbs.db
Desktop.ini
/.comet/
!/.comet/
/.comet/*
/.comet/config.yaml
!/.comet/config.yaml
# >>> Comet managed project state >>>
!/.comet/
/.comet/*
!/.comet/config.yaml
# <<< Comet managed project state <<<
@@ -0,0 +1,32 @@
# Outcome
修复梦三独立审查确认的两项战斗生命周期缺陷:整角色退出遗漏装备技能登记,以及引擎忽略异常时战斗准备失败导致外部控制器永久等待。目标是减少连续对局残留,并让准备失败能自动记录诊断、停止开战、进入已有异常退出提示。
# Scope
仅修改梦三的战斗准备、整角色技能释放及相关回归测试。调查依据为主工作区 docs/reviews/mengsan-20261013.md 的两项已确认问题,以及本地无名 1.11.5.2 的原生 GameEvent、装备触发登记和释放实现;审查报告用于定位,不作为全包功能改造需求。
修复基线为主工作区 main 的 28f7096 加用户已有的未提交受保护技能修复。进入 Build 后将以下已有文件的当前内容同步到隔离工作目录,再实施本次最小修复:.gitignore、mengsan/README.md、mengsan/battle/battle-flow.js、mengsan/battle/skill-teardown.js、mengsan/mode.js、tools/test_skill_teardown.mjs。保留这些文件中已有的结算提交状态、失败不重放及诊断行为,不修改主工作区。
关键行为:装备和已初始化的触发登记必须按角色释放;需要执行的原生装备卸载子事件在删除角色节点前完成。准备异常在梦三内容边界传递给外部控制器,不能依赖引擎 root.start() 是否拒绝。
# Non-goals
不改无名引擎或其他扩展;不清空公共技能/触发表;不删除 fixed、superCharlotte 标签或技能定义;不调整卡牌、羁绊、遗物、奖励规则。不全面修复审查中发现的旧测试夹具失配;只新增或调整本次相关有效回归。PC/安卓整局实机验证没有运行环境,不宣称已完成。此阶段不提交、合并、推送或发布,回到主工作区交付需在验证结果确认后决定。
# Acceptance examples
- 装备八卦等只登记于装备/已初始化技能来源的角色整局退出后,该旧角色的 initedSkills 和对应触发登记清除;连续退出多局不累积这些登记。
- 需要执行的装备卸载回调及其子事件在整角色销毁之前完成;回调失败可向外部传播并记录,已执行的释放回调不会因重试再次运行。
- 既有受保护技能策略保持:纯元数据标记可以保留在退场对象上,活动技能正确释放,局内单技能保护继续有效;其他角色持有的共享全局技能登记保持有效。
- 战斗准备抛出异常时,无论引擎 ignore_error 为 true 或 false,外部控制器都能收到原始异常并结束等待,进入已有战斗异常退出路径,不继续战斗或进入奖励结算。
- 准备失败生成梦三诊断记录并保留原始错误上下文;该失败路径不提交胜负结果、不改变上次成功存档、不发放奖励;正常准备和正常结算路径继续有效。
- 提供可重复执行的装备登记与原生事件异常回归,相关受保护技能/结算/诊断回归通过;独立只读验证报告明确列出未实测的 PC 和安卓整局限制。
# Constraints and invariants
只操作梦三拥有的战斗和角色资源。维持现有事件排空顺序、存档事务和奖励去重语义,不修改通用 Player.prototype。使用原生登记/卸载和 GameEvent 代码核对兼容性,不用与实现同形的空桩代替关键因果验证。
# Decisions
此前用户已选择在当前目录审查最新代码和未提交修复。此次 CLI 明确拒绝第二活动任务复用当前目录;因此为梦三单独创建 codex/mengsan-lifecycle-fixes 工作目录,原天书任务保留。两项缺陷涉及同一生命周期,采用一个 Native change 顺序处理,不拆分并行实现任务。正式验证使用新启动的 GPT-6.1 Sol 高推理只读验证者。
@@ -0,0 +1,255 @@
schema: comet.native.v4
name: mengsan-lifecycle-fixes
language: en
phase: archive
status: done
state_version: 11
brief: brief.md
document_constraints_version: 2
shape_confirmation_hash: e9c151b43d8a9b61035c6dbcf96cdc2d8e4c276b78f160cbc8bc26a67647abbf
spec_changes:
- capability: mengsan-battle-lifecycle
operation: create
source: specs/mengsan-battle-lifecycle/spec.md
workspace:
isolation: worktree
change_branch: codex/mengsan-lifecycle-fixes
target_branch: main
finish: merge
loop:
stage: done
goal_cycle: 1
iteration: 1
attempt: 1
retry_epoch: 0
failed_iteration_count: 0
no_progress_count: 0
execution_failure_count: 0
previous_unresolved_ids: []
next_action: null
acceptance:
- id: A1
source: brief.md
text: 装备八卦等只登记于装备/已初始化技能来源的角色整局退出后,该旧角色的 initedSkills 和对应触发登记清除;连续退出多局不累积这些登记。
result: passed
reason:
text: skill-teardown.js:125-130、142-150、184-216 清点 initedSkills 与装备技能,按角色使用原生
removeEquipTrigger/removeSkillTrigger。test_battle_lifecycle.mjs:108-140
使用本地1.11.5.2原生方法验证连续三局八卦旧角色登记归零、仅初始化来源和另一角色登记保留;Runtime regressions
第1-2行通过。
truncated: false
- id: A2
source: brief.md
text: 需要执行的装备卸载回调及其子事件在整角色销毁之前完成;回调失败可向外部传播并记录,已执行的释放回调不会因重试再次运行。
result: passed
reason:
text: skill-teardown.js:148-182 包装本次装备卸载产生的已编译事件及后代内容,排空后记录并抛出原始失败;93-117
正常退出释放根事件,131-133、223 缓存操作避免重放。battle-flow.js:52-60
在session.dispose前等待释放;test_battle_lifecycle.mjs:143-171、241-254
验证回调/子事件顺序、两种ignore_error失败传播、一次执行及栈排空,Runtime第3-5、9-10行通过。
truncated: false
- id: A3
source: brief.md
text: 既有受保护技能策略保持:纯元数据标记可以保留在退场对象上,活动技能正确释放,局内单技能保护继续有效;其他角色持有的共享全局技能登记保持有效。
result: passed
reason:
text: skill-teardown.js:5-15、184-197 保留纯元数据受保护标记,10-90
区分活动技能整角色卸载与局内保护。test_skill_teardown.mjs:40-119及121-158的9项回归通过;test_battle_lifecycle.mjs:127-140
以原生globalmap确认另一角色共享全局登记保留;未清空公共hook/skill或修改Player原型。
truncated: false
- id: A4
source: brief.md
text: 战斗准备抛出异常时,无论引擎 ignore_error 为 true 或
false,外部控制器都能收到原始异常并结束等待,进入已有战斗异常退出路径,不继续战斗或进入奖励结算。
result: passed
reason:
text: mode.js:900-919
在prepareBattle内容内保存原始错误、requestStop,root.start排空后唤醒外层;920-930
异常提示及模式选择分支绕过结算。与原生gameEvent.js:196-257吞错/栈弹出逻辑一致;test_battle_lifecycle.mjs:208-227
两种ignore_error在2秒内退出、Error身份一致、栈为空,Runtime第6-7行通过。
truncated: false
- id: A5
source: brief.md
text: 准备失败生成梦三诊断记录并保留原始错误上下文;该失败路径不提交胜负结果、不改变上次成功存档、不发放奖励;正常准备和正常结算路径继续有效。
result: passed
reason:
text: mode.js:900-906记录原始Error与sessionId/runId/nodeId;失败分支928返回,不进入930结算。mode-storage.js:11-16、125-126读取存档副本,准备期间不提交新结果;test_battle_lifecycle.mjs:208-239、256-280
验证失败存档基线不变、提交0次、实际临时诊断JSON含原栈/节点和正常提交1次。结算后清理失败不重发奖励及相关诊断/事务回归通过。
truncated: false
- id: A6
source: brief.md
text: 提供可重复执行的装备登记与原生事件异常回归,相关受保护技能/结算/诊断回归通过;独立只读验证报告明确列出未实测的 PC 和安卓整局限制。
result: passed
reason:
text: 完整brief和Spec的A1-A6均独立核查。Runtime state.json:5-8、25-70
当前候选绑定和工作区正确,regressions 46 tests/46 pass/0 fail/skip;syntax
exitCode=0。关键回归抽取本地1.11.5.2原生GameEvent、ArrayCompiler、Player与全局登记卸载源码;README.md:60-62提供重复入口及PC/安卓限制。复用有效检查,无重复整套运行。
truncated: false
builder_handoff:
candidate_id: 013d3893-4bba-4fb3-81e1-46b49e47e09b
identity_provider: skill-coordinated
builder_execution_ref: skill-coordinated:builder:7a547236-6f9c-4f1e-92e9-ae185431d904
iteration: 1
summary:
text: 补齐退场角色装备及 initedSkills
登记释放,等待并捕获装备卸载子事件异常;准备内容内捕获原始错误,停止会话,根事件正常排空后唤醒外部协调者。保留已同步的受保护技能兼容修复、提交状态和奖励去重。
truncated: false
addressed_acceptance_ids:
- A1
- A2
- A3
- A4
- A5
- A6
acceptance_review:
- id: A1
status: implemented-with-evidence
evidence:
- mengsan/battle/skill-teardown.js:createPlayerTeardown;tools/test_battle_lifecycle.mjs
连续三局原生八卦注册释放及仅 initedSkills 来源回归通过
note: 按角色调用原生 removeEquipTrigger/removeSkillTrigger,补齐登记来源,不清空公共表。
- id: A2
status: implemented-with-evidence
evidence:
- skill-teardown.js 装备事件局部边界和 waitNext;原生 onLose/嵌套子事件错误在 ignore_error
true/false 均传播、队列排空和一次执行回归通过
note: 事件内容已编译,仅包装当前装备卸载产生的事件及后代;复用缓存操作避免重放。
- id: A3
status: implemented-with-evidence
evidence:
- tools/test_skill_teardown.mjs 9/9 通过;test_battle_lifecycle 原生
addGlobalSkill/removeGlobalSkill 保留另一角色所有权回归通过
note: 同步并保留现有保护策略、纯元数据标记和局内单技能边界。
- id: A4
status: implemented-with-evidence
evidence:
- mengsan/mode.js:playBattle 内容内 failBattle 边界;原生
GameEvent.loop/ArrayCompiler 两种 ignore_error 准备抛错回归通过
note: 原始错误在引擎可能吞错前保存;正常根事件排空后结束外层等待,不进入结算。
- id: A5
status: implemented-with-evidence
evidence:
- test_battle_lifecycle 准备失败写出实际临时报告
JSON,检查原始错误栈/节点;存档基线不变、提交计数0;正常结算计数1;protected teardown 提交后失败不重发奖励回归通过
note: 调用现有诊断接口,不改存档事务,失败走保留存档退出路径。
- id: A6
status: implemented-with-evidence
evidence:
- 新增 tools/test_battle_lifecycle.mjs 11/11 通过,相关 skill teardown 9/9
通过;mengsan/README.md 记录回归入口及实机限制
note: 已提供可重复回归和正式检查计划,提交后按 Native 协议启动新的只读验证者;正式验收结果尚未宣称通过。
checks:
- name:
text: targeted lifecycle and protected teardown
truncated: false
result: passed
note:
text: 20/20;后续仅格式调整后生命周期11/11再次通过。
truncated: false
- name:
text: diff whitespace
truncated: false
result: passed
note:
text: git diff --check 通过;正式候选需复核最终修改。
truncated: false
checks_truncated: false
known_limits:
- text: 未执行PC或安卓客户端整局;本地引擎证据为无名1.11.5.2的原生事件/编译器和登记卸载函数,外围服务为最小依赖桩。
truncated: false
- text: 未修改其他扩展或无名引擎;不承诺所有外部扩展及所有客户端版本兼容。
truncated: false
- text: 旧审查的16个旧夹具失败未全面改造,本次仅相关有效回归。
truncated: false
- text: 实现位于独立工作目录;尚未提交合并推送发布。
truncated: false
- text: codebase-memory技能已读取,但本会话未暴露图谱工具,结构证据以源码核对为准。
truncated: false
known_limits_truncated: false
review: null
submitted_at: 2026-10-13T02:25:19.819Z
blockers: []
verification:
candidate_id: 013d3893-4bba-4fb3-81e1-46b49e47e09b
identity_provider: skill-coordinated
verifier_execution_ref: skill-coordinated:verifier:59515ca5-0d91-4ded-9343-b64a59ac88d9
iteration: 1
attempt: 1
assurance: skill-coordinated
verdict: pass
checks:
- id: regressions
name:
text: relevant-regressions
truncated: false
argv_display:
- text: -NoProfile
truncated: false
- text: -Command
truncated: false
- text: $env:NONAME_ROOT = 'D:\Work\noname\resources\app'; & 'C:\Program
Files\nodejs\node.exe' --experimental-vm-modules --test
tools/test_battle_lifecycle.mjs tools/test_skill_teardown.mjs
tools/test_diagnostics.mjs tools/test_mobile_memory.mjs
tools/test_intent.mjs tools/test_event_compilation.mjs; exit
$LASTEXITCODE
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 763
- id: syntax
name:
text: syntax-and-whitespace
truncated: false
argv_display:
- text: -NoProfile
truncated: false
- text: -Command
truncated: false
- text: "& 'C:\\Program Files\\nodejs\\node.exe' --check
mengsan/battle/skill-teardown.js; if ($LASTEXITCODE -ne 0) { exit
$LASTEXITCODE }; & 'C:\\Program Files\\nodejs\\node.exe' --check
mengsan/mode.js; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE };
git diff --check; exit $LASTEXITCODE"
truncated: false
argv_truncated: false
cwd_ref: .
status: passed
exit_code: 0
duration_ms: 461
summary:
text: 独立只读验收A1-A6全部通过;当前候选正式回归46/46与语法检查通过,未发现本次范围内实质失败。PC/安卓整局未实测。
truncated: false
risks:
- text: 未执行PC或安卓客户端完整对局;验收依据为源码核查和自动化回归,不能视为实机证明。
truncated: false
- text: 原生事件/编译器/登记卸载方法由本地无名1.11.5.2源码抽取,事件管理器、界面、外围服务和结算入口有最小依赖桩;不承诺其他客户端版本或所有外部扩展改写兼容。
truncated: false
risks_truncated: false
completed_at: 2026-10-13T02:32:58.872Z
history:
- goal_cycle: 1
iteration: 1
attempt: 1
outcome: pass
unresolved_ids: []
summary:
text: 独立只读验收A1-A6全部通过;当前候选正式回归46/46与语法检查通过,未发现本次范围内实质失败。PC/安卓整局未实测。
truncated: false
completed_at: 2026-10-13T02:32:58.872Z
history_overflow:
dropped_entries: 0
first_dropped_at: null
last_dropped_at: null
outcome_counts:
pass: 0
fail: 0
blocked: 0
execution-error: 0
recovery: 0
verification_result: pass
verification_report: verification.md
archived: true
created_at: 2026-10-13T02:05:19.971Z
@@ -0,0 +1,47 @@
# 梦三战斗生命周期
## 资源所有权与退出顺序
梦三每场战斗拥有自己的会话、角色及相关资源。正常结算先停止玩法根事件并排空子事件,再提交结果,完成整角色技能和装备登记的释放,最后销毁角色及界面资源并返回地图或结局。释放失败保留已提交结果,不重复奖励,不盲目重放部分执行过的回调。
整角色退出的登记清点包括角色技能、隐藏技能、不可见技能、临时技能、附加技能、装备技能及已初始化的触发登记。释放仅针对当前退场角色,使用引擎的角色级卸载能力,保留其他角色的共享登记;不能以清空公共 lib.hook、删除 lib.skill 定义或修改全局 Player 原型实现。装备卸载要求的回调及子事件必须在角色节点销毁前完成。
### Scenario: 装备登记完整释放
Acceptance: A1
装备八卦等技能只通过装备触发接口登记、没有出现在 player.skills 时,整角色退出仍移除该角色的相应 initedSkills 和触发条目。重复多场独立战斗退出后,不累积旧角色登记。
### Scenario: 卸载回调完成与失败传播
Acceptance: A2
存在原生装备卸载回调时,退出流程执行并等待其子事件后才能销毁角色。回调失败时记录失败并向外层传播,不能报为成功;同一退出操作重试不会重新调用已执行的释放回调。
## 受保护技能和共享登记
fixed 和 superCharlotte 是需要识别的兼容标记,不删除定义上的标记。纯元数据标记可保留在退场对象上;具有触发器、mod、onremove 或标记资源回调等活动行为的技能在整角色退出时需要释放。局内移除单技能继续遵守保护边界。一个角色退出不应撤销仍由其他角色持有的共享全局技能。
### Scenario: 保留既有兼容边界
Acceptance: A3
纯元数据 chaofeng_common 类标记不会阻止退出;活动受保护技能被释放且标记定义不变。局内单技能保护有效,其他角色持有的共享全局登记在退场角色卸载后仍有效。
## 战斗准备的异常边界
战斗准备使用梦三自己的内容边界捕获异常,保存原始错误并通知外部战斗协调者;不能仅依赖引擎对 root.start() 的拒绝。协调者结束等待后进入现有战斗异常提示及保留存档退出路径,停止继续开战。错误消息与原始栈、会话和准备阶段上下文通过现有梦三诊断系统记录。
### Scenario: 引擎忽略异常仍结束等待
Acceptance: A4
准备抛错,在 ignore_error 开启或关闭时均将原始错误送达外部协调者、结束等待、走战斗异常退出路径;不开始后续战斗或奖励结算。正常内容边界不将成功准备误判为异常。
### Scenario: 诊断与存档不变性
Acceptance: A5
准备失败留下可定位的梦三诊断错误记录;失败前已保存的征程仍有效,不写入新胜负结果或奖励。正常准备和正常结算仍按原先事务提交和奖励去重行为运行。
## 验证边界
### Scenario: 可重复回归与独立验证
Acceptance: A6
关键装备登记和事件吞错回归使用本地原生引擎实现或可验证的原生接口接入,在项目中可重复执行。相关受保护技能、结算和诊断检查通过,由独立只读验证者核查六项验收。验证记录明确指出仅有源码和自动化回归证据,没有 PC/安卓整局实机证明。
@@ -0,0 +1,66 @@
---
generated_from_state_version: 11
---
# Verification
## Current result
- Result: **Archived**
- Verification status: **Checks completed; result confirmed**
- Goal cycle: 1
- Iteration: 1
- Verifier attempt: 1
- Completed: 2026-10-13T02:32:58.872Z
- Summary: 独立只读验收A1-A6全部通过;当前候选正式回归46/46与语法检查通过,未发现本次范围内实质失败。PC/安卓整局未实测。
## Acceptance
| ID | Result | Source | Criterion | Reason |
| --- | --- | --- | --- | --- |
| A1 | passed | brief.md | 装备八卦等只登记于装备/已初始化技能来源的角色整局退出后,该旧角色的 initedSkills 和对应触发登记清除;连续退出多局不累积这些登记。 | skill-teardown.js:125-130、142-150、184-216 清点 initedSkills 与装备技能,按角色使用原生 removeEquipTrigger/removeSkillTrigger。test_battle_lifecycle.mjs:108-140 使用本地1.11.5.2原生方法验证连续三局八卦旧角色登记归零、仅初始化来源和另一角色登记保留;Runtime regressions 第1-2行通过。 |
| A2 | passed | brief.md | 需要执行的装备卸载回调及其子事件在整角色销毁之前完成;回调失败可向外部传播并记录,已执行的释放回调不会因重试再次运行。 | skill-teardown.js:148-182 包装本次装备卸载产生的已编译事件及后代内容,排空后记录并抛出原始失败;93-117 正常退出释放根事件,131-133、223 缓存操作避免重放。battle-flow.js:52-60 在session.dispose前等待释放;test_battle_lifecycle.mjs:143-171、241-254 验证回调/子事件顺序、两种ignore_error失败传播、一次执行及栈排空,Runtime第3-5、9-10行通过。 |
| A3 | passed | brief.md | 既有受保护技能策略保持:纯元数据标记可以保留在退场对象上,活动技能正确释放,局内单技能保护继续有效;其他角色持有的共享全局技能登记保持有效。 | skill-teardown.js:5-15、184-197 保留纯元数据受保护标记,10-90 区分活动技能整角色卸载与局内保护。test_skill_teardown.mjs:40-119及121-158的9项回归通过;test_battle_lifecycle.mjs:127-140 以原生globalmap确认另一角色共享全局登记保留;未清空公共hook/skill或修改Player原型。 |
| A4 | passed | brief.md | 战斗准备抛出异常时,无论引擎 ignore_error 为 true 或 false,外部控制器都能收到原始异常并结束等待,进入已有战斗异常退出路径,不继续战斗或进入奖励结算。 | mode.js:900-919 在prepareBattle内容内保存原始错误、requestStop,root.start排空后唤醒外层;920-930 异常提示及模式选择分支绕过结算。与原生gameEvent.js:196-257吞错/栈弹出逻辑一致;test_battle_lifecycle.mjs:208-227 两种ignore_error在2秒内退出、Error身份一致、栈为空,Runtime第6-7行通过。 |
| A5 | passed | brief.md | 准备失败生成梦三诊断记录并保留原始错误上下文;该失败路径不提交胜负结果、不改变上次成功存档、不发放奖励;正常准备和正常结算路径继续有效。 | mode.js:900-906记录原始Error与sessionId/runId/nodeId;失败分支928返回,不进入930结算。mode-storage.js:11-16、125-126读取存档副本,准备期间不提交新结果;test_battle_lifecycle.mjs:208-239、256-280 验证失败存档基线不变、提交0次、实际临时诊断JSON含原栈/节点和正常提交1次。结算后清理失败不重发奖励及相关诊断/事务回归通过。 |
| A6 | passed | brief.md | 提供可重复执行的装备登记与原生事件异常回归,相关受保护技能/结算/诊断回归通过;独立只读验证报告明确列出未实测的 PC 和安卓整局限制。 | 完整brief和Spec的A1-A6均独立核查。Runtime state.json:5-8、25-70 当前候选绑定和工作区正确,regressions 46 tests/46 pass/0 fail/skip;syntax exitCode=0。关键回归抽取本地1.11.5.2原生GameEvent、ArrayCompiler、Player与全局登记卸载源码;README.md:60-62提供重复入口及PC/安卓限制。复用有效检查,无重复整套运行。 |
## Checks
| Check | Command | Working directory | Status | Exit | Duration |
| --- | --- | --- | --- | ---: | ---: |
| relevant-regressions | -NoProfile -Command $env:NONAME_ROOT = 'D:\Work\noname\resources\app'; & 'C:\Program Files\nodejs\node.exe' --experimental-vm-modules --test tools/test_battle_lifecycle.mjs tools/test_skill_teardown.mjs tools/test_diagnostics.mjs tools/test_mobile_memory.mjs tools/test_intent.mjs tools/test_event_compilation.mjs; exit $LASTEXITCODE | . | passed | 0 | 763 ms |
| syntax-and-whitespace | -NoProfile -Command & 'C:\Program Files\nodejs\node.exe' --check mengsan/battle/skill-teardown.js; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; & 'C:\Program Files\nodejs\node.exe' --check mengsan/mode.js; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; git diff --check; exit $LASTEXITCODE | . | passed | 0 | 461 ms |
### Builder-reported evidence
These are Builder reports, not Runtime check receipts or independent verification results.
- targeted lifecycle and protected teardown: passed — 20/20;后续仅格式调整后生命周期11/11再次通过。
- diff whitespace: passed — git diff --check 通过;正式候选需复核最终修改。
- Known limitation: 未执行PC或安卓客户端整局;本地引擎证据为无名1.11.5.2的原生事件/编译器和登记卸载函数,外围服务为最小依赖桩。
- Known limitation: 未修改其他扩展或无名引擎;不承诺所有外部扩展及所有客户端版本兼容。
- Known limitation: 旧审查的16个旧夹具失败未全面改造,本次仅相关有效回归。
- Known limitation: 实现位于独立工作目录;尚未提交合并推送发布。
- Known limitation: codebase-memory技能已读取,但本会话未暴露图谱工具,结构证据以源码核对为准。
## Blockers
_None._
## Risks and skipped work
- 未执行PC或安卓客户端完整对局;验收依据为源码核查和自动化回归,不能视为实机证明。
- 原生事件/编译器/登记卸载方法由本地无名1.11.5.2源码抽取,事件管理器、界面、外围服务和结算入口有最小依赖桩;不承诺其他客户端版本或所有外部扩展改写兼容。
## Previous iterations
| Goal cycle | Iteration | Attempt | Outcome | Unresolved | Summary | Completed |
| ---: | ---: | ---: | --- | --- | --- | --- |
| 1 | 1 | 1 | pass | — | 独立只读验收A1-A6全部通过;当前候选正式回归46/46与语法检查通过,未发现本次范围内实质失败。PC/安卓整局未实测。 | 2026-10-13T02:32:58.872Z |
## Conclusion
独立只读验收A1-A6全部通过;当前候选正式回归46/46与语法检查通过,未发现本次范围内实质失败。PC/安卓整局未实测。
@@ -0,0 +1,47 @@
# 梦三战斗生命周期
## 资源所有权与退出顺序
梦三每场战斗拥有自己的会话、角色及相关资源。正常结算先停止玩法根事件并排空子事件,再提交结果,完成整角色技能和装备登记的释放,最后销毁角色及界面资源并返回地图或结局。释放失败保留已提交结果,不重复奖励,不盲目重放部分执行过的回调。
整角色退出的登记清点包括角色技能、隐藏技能、不可见技能、临时技能、附加技能、装备技能及已初始化的触发登记。释放仅针对当前退场角色,使用引擎的角色级卸载能力,保留其他角色的共享登记;不能以清空公共 lib.hook、删除 lib.skill 定义或修改全局 Player 原型实现。装备卸载要求的回调及子事件必须在角色节点销毁前完成。
### Scenario: 装备登记完整释放
Acceptance: A1
装备八卦等技能只通过装备触发接口登记、没有出现在 player.skills 时,整角色退出仍移除该角色的相应 initedSkills 和触发条目。重复多场独立战斗退出后,不累积旧角色登记。
### Scenario: 卸载回调完成与失败传播
Acceptance: A2
存在原生装备卸载回调时,退出流程执行并等待其子事件后才能销毁角色。回调失败时记录失败并向外层传播,不能报为成功;同一退出操作重试不会重新调用已执行的释放回调。
## 受保护技能和共享登记
fixed 和 superCharlotte 是需要识别的兼容标记,不删除定义上的标记。纯元数据标记可保留在退场对象上;具有触发器、mod、onremove 或标记资源回调等活动行为的技能在整角色退出时需要释放。局内移除单技能继续遵守保护边界。一个角色退出不应撤销仍由其他角色持有的共享全局技能。
### Scenario: 保留既有兼容边界
Acceptance: A3
纯元数据 chaofeng_common 类标记不会阻止退出;活动受保护技能被释放且标记定义不变。局内单技能保护有效,其他角色持有的共享全局登记在退场角色卸载后仍有效。
## 战斗准备的异常边界
战斗准备使用梦三自己的内容边界捕获异常,保存原始错误并通知外部战斗协调者;不能仅依赖引擎对 root.start() 的拒绝。协调者结束等待后进入现有战斗异常提示及保留存档退出路径,停止继续开战。错误消息与原始栈、会话和准备阶段上下文通过现有梦三诊断系统记录。
### Scenario: 引擎忽略异常仍结束等待
Acceptance: A4
准备抛错,在 ignore_error 开启或关闭时均将原始错误送达外部协调者、结束等待、走战斗异常退出路径;不开始后续战斗或奖励结算。正常内容边界不将成功准备误判为异常。
### Scenario: 诊断与存档不变性
Acceptance: A5
准备失败留下可定位的梦三诊断错误记录;失败前已保存的征程仍有效,不写入新胜负结果或奖励。正常准备和正常结算仍按原先事务提交和奖励去重行为运行。
## 验证边界
### Scenario: 可重复回归与独立验证
Acceptance: A6
关键装备登记和事件吞错回归使用本地原生引擎实现或可验证的原生接口接入,在项目中可重复执行。相关受保护技能、结算和诊断检查通过,由独立只读验证者核查六项验收。验证记录明确指出仅有源码和自动化回归证据,没有 PC/安卓整局实机证明。
+22
View File
@@ -39,6 +39,28 @@
手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见
[手牌 UI 子扩展说明](docs/ui-hand-subextension.md)。
战后技能清理只针对本场角色。`fixed` / `superCharlotte` 的纯元数据技能
保留在即将销毁的角色上,不阻断结算;含触发器、规则修正、标记清理或
释放回调的技能在整角色销毁时正常卸载,等待回调完成。公共技能定义及
保护标记不变,战斗期间单技能移除仍受保护。真实清理失败不会重复执行
已开始的释放回调;提示明确区分未保存与已保存后失败。
梦三保留必要引擎规则、已使用的原生角色技能和外部界面功能,不按技能
前缀或保护标记批量禁用全局技能。战斗日志记录进入时的全局技能快照,
以及角色技能保留、卸载和失败信息。外部玩法冲突需根据证据单独适配,
本策略不承诺兼容所有外部技能或对引擎函数的改写。
开发回归检查:`node --test tools/test_skill_teardown.mjs`。
整角色退出还会卸载装备及已初始化的触发登记,等待装备卸载子事件完成;
卸载异常自动生成诊断报告,失败操作不重复执行。战斗准备异常由梦三
直接捕获,保留原始错误及会话信息,不依赖引擎是否忽略错误;准备失败
进入保留存档退出提示,不发放奖励。
原生引擎回归:设置 `NONAME_ROOT` 为无名的应用目录(包含 `noname/`),
执行 `node --test tools/test_battle_lifecycle.mjs`。该回归不能代替
PC 和安卓客户端的完整对局验证。
梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
+3
View File
@@ -92,6 +92,9 @@ export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMa
return Object.freeze({
get state() { return state; },
get error() { return error; },
get settlementStatus() {
return { committed: !!job?.committed, phase };
},
async enterBattle({session, run, node, encounter, start}) {
if (state !== "map") throw new Error("Map handoff not ready");
const saved = settlement.readRun();
+91 -13
View File
@@ -1,9 +1,16 @@
// Candidate-only engine-derived release. Never replaces Player.prototype or lib.skill.
// Restricted: additional/fixed/superCharlotte skills require a separate audited adapter.
// Protected gameplay skills stay guarded; whole-player disposal owns every source.
import { getDiagnostics } from "../../diagnostics/index.js";
const markerFields = new Set(["fixed", "superCharlotte", "charlotte", "forced",
"unique", "popup", "silent", "ai", "skill_id", "_priority", "priority",
"locked", "nopop"]);
const protectedMarker = info => Boolean((info?.fixed || info?.superCharlotte) &&
Reflect.ownKeys(info).every(key => markerFields.has(key)));
export async function removeBattleSkill(player, skill, {lib,game,get,_status}, wholePlayer = false) {
const names = Array.isArray(skill) ? skill.slice() : [skill];
for (const name of names) {
if (lib.skill[name]?.fixed || lib.skill[name]?.superCharlotte) throw new Error("Unsupported protected skill: " + name);
if (!wholePlayer && (lib.skill[name]?.fixed || lib.skill[name]?.superCharlotte)) throw new Error("Unsupported protected skill: " + name);
}
if (!wholePlayer && Object.values(player.additionalSkills || {}).some(list => list?.length)) throw new Error("Additional skill release adapter required");
const remove = async function removeSkill(skill) {
@@ -21,7 +28,7 @@ export async function removeBattleSkill(player, skill, {lib,game,get,_status}, w
return;
} else {
var info = lib.skill[skill];
if (info?.fixed && arguments[1] !== true) {
if (info?.fixed && !wholePlayer) {
return skill;
}
this.unmarkSkill(skill);
@@ -52,11 +59,11 @@ export async function removeBattleSkill(player, skill, {lib,game,get,_status}, w
cards = [cards];
}
if (get.itemtype(cards) == "cards") {
if (this.onremove == "discard") {
if (info.onremove == "discard") {
this.$throw(cards);
}
if (this.onremove == "discard" || this.onremove == "lose") {
game.cardsDiscard(cards);
if (info.onremove == "discard" || info.onremove == "lose") {
await game.cardsDiscard(cards);
delete this.storage[skill];
}
}
@@ -89,7 +96,7 @@ export async function runSkillTeardown(releases, {lib,_status}) {
if (!Array.isArray(releases) || releases.some(fn => typeof fn !== "function")) throw new TypeError("Explicit release callbacks required");
const previous = _status.eventManager.rootEvent;
const root = new lib.element.GameEvent("mengsanSkillTeardown", false, _status.eventManager);
let releaseError;
let releaseError, releaseFailed = false;
root.setContent(async () => {
try {
for (const release of releases) {
@@ -97,6 +104,7 @@ export async function runSkillTeardown(releases, {lib,_status}) {
await root.waitNext();
}
} catch (error) {
releaseFailed = true;
releaseError = error;
// Let the engine drain any queued work and pop this root normally.
}
@@ -105,16 +113,18 @@ export async function runSkillTeardown(releases, {lib,_status}) {
await root.start();
if (_status.eventManager.eventStack.includes(root)) throw new Error("Teardown event still active");
if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previous;
if (releaseError) throw releaseError;
if (releaseFailed) throw releaseError;
}
// Whole-player disposal differs from removing one source during gameplay: every source is ending.
// Each callback runs at most once; failed/partially executed teardown is NOT replayed blindly.
export function createPlayerTeardown(players, env) {
const captured = players.slice();
const diagnostics = getDiagnostics().scope("mengsan.skillTeardown");
let operation;
const names = player => [...new Set([
...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []),
...(player.initedSkills || []),
...Object.keys(player.tempSkills || {}),
...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []),
])];
@@ -122,18 +132,86 @@ export function createPlayerTeardown(players, env) {
if (operation) return operation;
operation = (async () => {
// Preflight ALL players before modifying any skill.
for (const player of captured) for (const name of names(player)) {
if (env.lib.skill[name]?.fixed || env.lib.skill[name]?.superCharlotte) throw new Error("Protected skill requires adapter: " + name);
for (const player of captured) {
if (Object.values(player.additionalSkills || {}).some(value => !Array.isArray(value))) throw new Error("Unsupported additional skill source shape");
}
await runSkillTeardown(captured.map(player => async () => {
const released = new Set();
while (names(player).length) {
const retained = new Set();
const equipmentSkills = new Set();
for (const card of player.getVCards?.("e") || []) {
const context = { playerId: player.playerid,
character: player.name, cardName: card.name };
try {
const skills = env.get.info(card, false)?.skills || [];
for (const name of skills) equipmentSkills.add(name);
const root = env._status.event;
const existing = new Set(root.next);
player.removeEquipTrigger(card);
let callbackError, callbackFailed = false;
const guarded = new WeakSet();
const guard = child => {
if (guarded.has(child)) return;
guarded.add(child);
const content = child.content;
child.content = async event => {
try { await Reflect.apply(content, child, [event]); }
catch (error) {
if (!callbackFailed) callbackError = error;
callbackFailed = true;
event.finish();
}
};
const waitNext = child.waitNext;
child.waitNext = function (...args) {
for (const next of [...this.next, ...this.after]) {
guard(next);
}
return Reflect.apply(waitNext, this, args);
};
};
for (const child of root.next) {
if (!existing.has(child)) guard(child);
}
await root.waitNext();
if (callbackFailed) throw callbackError;
diagnostics.info("equipment.released", context);
} catch (error) {
diagnostics.error("equipment.release.failed", error, context);
throw error;
}
}
const candidates = () => [...new Set([
...names(player), ...equipmentSkills,
])].filter(name => {
const info = env.lib.skill[name];
if (!protectedMarker(info)) return true;
if (!retained.has(name)) {
retained.add(name);
diagnostics.info("skill.retained", { skill: name,
playerId: player.playerid, character: player.name,
fixed: !!info.fixed, superCharlotte: !!info.superCharlotte,
reason: "metadataOnly" });
}
return false;
});
while (candidates().length) {
if (released.size >= 128) throw new Error("Skill teardown exceeded bounded expansion");
const name = names(player)[0];
const name = candidates()[0];
if (released.has(name)) throw new Error("Released skill was re-added: " + name);
await removeBattleSkill(player, name, env, true);
const info = env.lib.skill[name];
const context = { skill: name, playerId: player.playerid,
character: player.name, fixed: !!info?.fixed,
superCharlotte: !!info?.superCharlotte };
try {
await removeBattleSkill(player, name, env, true);
diagnostics.info("skill.released", context);
} catch (error) {
diagnostics.error("skill.release.failed", error, context);
throw error;
}
released.add(name);
equipmentSkills.delete(name);
// Children can add skills. Drain them before taking the next inventory.
await env._status.event.waitNext();
}
+32 -6
View File
@@ -681,6 +681,10 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
run.player.permanentSkills.forEach(skill => {
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
});
diagnostics.info("battle.globalSkills.snapshot", {
sessionId: session.id,
existingGlobalSkills: [...(lib.skill.global || [])],
});
for (const skill of [
"mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_ic_bridge_shuying", "mengsan_shared_bridge_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_intent_refresh_shuying",
@@ -851,10 +855,18 @@ const awaitSettlement = async (flow, initial = flow.wait()) => {
catch (error) {
if (flow.state !== "retryable") throw error;
diagnostics.error("settlement.prompt", error, { flowState: flow.state });
const action = await chooseButtons("结算未完成", [
{id:"retry",name:"重试保存与结算"},
const saved = flow.settlementStatus.committed;
const skillFailure = flow.settlementStatus.phase === "releaseSkills";
const actions = [
{id:"exit",name:"保留已保存进度并返回模式选择"},
], "已提交的奖励不会重发。技能释放失败时请返回模式选择,避免重复调用释放回调。");
];
if (!skillFailure) actions.unshift({id:"retry",
name:saved ? "重试后续结算" : "重试保存与结算"});
const action = await chooseButtons(saved ? "进度已保存,后续结算未完成" : "结算尚未保存",
actions, skillFailure
? "进度已保存;战后技能清理失败,请返回模式选择。已提交的奖励不会重发。"
: saved ? "本次进度已保存,可重试后续流程。已提交的奖励不会重发。"
: "本次结果尚未保存,旧存档保留,可重试保存。");
diagnostics.info("settlement.prompt.action", { action });
if (action === "exit") { await openModeSelection(); throw error; }
operation = flow.retry();
@@ -885,13 +897,27 @@ const playBattle = async (run, node, encounter) => {
session.ownResource({}, () => {
if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previousRoot;
});
root.setContent(async () => { await prepareBattle(run, node, encounter, session, resources); });
const failBattle = (error, stage) => {
if (current.engineFailed) return;
current.engineFailed = true;
current.engineError = error;
diagnostics.error("battle." + stage + ".failed", error, {
sessionId: session.id, runId: run.runId, nodeId: node.id,
});
session.requestStop();
};
root.setContent(async () => {
try { await prepareBattle(run, node, encounter, session, resources); }
catch (error) { failBattle(error, "prepare"); }
});
observeFreshEvent(session, root);
await flow.enterBattle({session,run,node,encounter,start() {
root.start().catch(error => { current.engineError = error; finished(); });
root.start().catch(error => { failBattle(error, "engine"); }).then(() => {
if (current.engineFailed) finished();
});
}});
await signal; // External controller, never an engine event content await.
if (current.engineError) {
if (current.engineFailed) {
reportFlowError(current.engineError);
session.requestStop();
current.handUI?.dispose();
+288
View File
@@ -0,0 +1,288 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
mkdtemp, readdir, readFile, writeFile, unlink, rmdir,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { createPlayerTeardown } from "../mengsan/battle/skill-teardown.js";
import { createBattleSession } from "../mengsan/battle/battle-session.js";
import { observeFreshEvent } from "../mengsan/battle/engine-session.js";
import {
createBattleFlow, quiesceEngine,
} from "../mengsan/battle/battle-flow.js";
import { createDiagnostics } from "../diagnostics/index.js";
const engineRoot = process.env.NONAME_ROOT;
assert.ok(engineRoot, "Set NONAME_ROOT to the noname application directory");
const readEngine = file => readFileSync(path.join(engineRoot, file), "utf8");
const classBody = source => source.slice(
source.indexOf("class "), source.lastIndexOf("export {"));
for (const [name, value] of Object.entries({
add(value) { if (!this.includes(value)) this.push(value); return this; },
addArray(values) {
for (const value of values) this.add(value);
return this;
},
remove(value) {
const index = this.indexOf(value);
if (index >= 0) this.splice(index, 1);
return this;
},
})) Object.defineProperty(Array.prototype, name, { value, configurable: true });
function engine(ignore_error = true) {
const manager = { eventStack: [], tempEvent: null, rootEvent: null,
setStatusEvent(event) { this.eventStack.push(event); },
popStatusEvent() { this.eventStack.pop(); } };
const _status = {
eventManager: manager, pauseManager: { async waitPause() {} },
};
Object.defineProperty(_status, "event", {
get: () => manager.eventStack.at(-1) || manager.rootEvent,
});
const lib = { config: { ignore_error }, skill: { global: [], globalmap: {} },
hook: { globalskill: {} }, hookmap: {}, relatedTrigger: {}, element: {} };
const history = [];
const game = { online: false, globalEventHandlers: { addHandlerToEvent() {} },
getGlobalHistory: () => history,
print() {}, expandSkills() {}, callHook() {},
broadcastAll(fn, ...args) { fn(...args); } };
const get = {
info: card => card.info,
skillsFromEquips: cards => cards.flatMap(card => card.info.skills || []),
};
const prefix = "noname/library/element/GameEvent/compilers/";
const Base = new Function("game", "_status",
classBody(readEngine(prefix + "ContentCompilerBase.js")) +
";return ContentCompilerBase;")(game, _status);
const ArrayCompiler = new Function("ContentCompilerBase",
classBody(readEngine(prefix + "ArrayCompiler.js")) +
";return ArrayCompiler;")(Base);
const arrayCompiler = new ArrayCompiler();
const compiler = { compile(content) {
return arrayCompiler.compile(Array.isArray(content) ? content : [content]);
} };
const GameEvent = new Function(
"compiler", "_status", "game", "lib", "get", "ui",
classBody(readEngine("noname/library/element/gameEvent.js")) +
";return GameEvent;")(compiler, _status, game, lib, get, {});
lib.element.GameEvent = GameEvent;
game.createEvent = name => {
const event = new GameEvent(name, false, manager);
_status.event.next.push(event);
return event;
};
const source = readEngine("noname/library/element/player.js");
const method = (start, end, text = source) => {
const from = text.indexOf(start);
assert.ok(from >= 0);
const to = text.indexOf(end, from);
assert.ok(to > from);
return text.slice(from, to);
};
const gameSource = readEngine("noname/game/index.js");
Object.assign(game, new Function("lib", "return {" +
method(" addGlobalSkill(", " removeGlobalSkill(", gameSource) + "," +
method(" removeGlobalSkill(", " resetSkills(", gameSource) + "}")(lib));
const native = new Function("lib", "game", "get", "_status", "return {" +
method(" addSkillTrigger(", " addSkillLog(") + "," +
method(" removeEquipTrigger(", " removeSkillTrigger(") + "," +
method(" removeSkillTrigger(", " removeSkill(") + "}")(
lib, game, get, _status);
manager.rootEvent = new GameEvent("initial", false, manager);
function player(id, cards = []) {
return { ...native, name: "liubei", playerid: id,
initedSkills: [], skills: [], hiddenSkills: [],
invisibleSkills: [], tempSkills: {}, additionalSkills: {}, storage: {},
vcardsMap: { equips: cards },
getVCards() { return this.vcardsMap.equips.slice(); },
getSkills() { return this.skills.slice(); },
unmarkSkill() {}, checkConflict() {}, enableSkill() {},
isDead: () => false, isOut: () => false, skipList: [] };
}
return { lib, game, get, _status, player, history };
}
test("native equipment hooks do not accumulate", async () => {
const env = engine();
env.lib.skill.bagua_skill = { equipSkill: true,
trigger: { player: ["chooseToRespondBegin", "chooseToUseBegin"] } };
const oldHooks = () => Object.keys(env.lib.hook)
.filter(key => key.startsWith("old")).length;
for (let i = 0; i < 3; i++) {
const player = env.player("old" + i, [
{ name: "bagua", info: { skills: ["bagua_skill"] } },
]);
player.addSkillTrigger("bagua_skill");
assert.equal(oldHooks(), 2);
await createPlayerTeardown([player], env)();
assert.deepEqual(player.initedSkills, []);
assert.equal(oldHooks(), 0);
assert.equal(player.getVCards("e").length, 0);
}
});
test("native registration release preserves another owner", async () => {
const env = engine();
env.lib.skill.source = { trigger: { player: "damage" }, global: "shared" };
env.lib.skill.shared = { trigger: { global: "phaseBegin" } };
const old = env.player("old"), other = env.player("other");
old.addSkillTrigger("source"); other.addSkillTrigger("source");
await createPlayerTeardown([old], env)();
assert.deepEqual(old.initedSkills, []);
assert.deepEqual(other.initedSkills, ["source"]);
assert.equal(env.lib.hook.old_player_damage, undefined);
assert.deepEqual(env.lib.hook.other_player_damage, ["source"]);
assert.deepEqual(env.lib.skill.globalmap.shared, [other]);
assert.ok(env.lib.skill.global.includes("shared"));
assert.deepEqual(env.lib.hook.globalskill.global_phaseBegin, ["shared"]);
});
test("native onLose children finish before disposal", async () => {
const env = engine(); const order = [];
const card = { name: "equipment", info: { skills: [], clearLose: true,
async onLose() {
order.push("onLose");
env.game.createEvent("child").setContent(async () => {
await Promise.resolve(); order.push("child");
});
} } };
await createPlayerTeardown([env.player("old", [card])], env)();
order.push("dispose");
assert.deepEqual(order, ["onLose", "child", "dispose"]);
assert.equal(env._status.eventManager.eventStack.length, 0);
});
for (const ignore_error of [true, false]) test(
`onLose failure propagates once: ignore_error=${ignore_error}`, async () => {
const env = engine(ignore_error); let count = 0;
const original = new Error("equipment cleanup failed");
const card = { name: "equipment", info: { skills: [], clearLose: true,
async onLose() { count++; throw original; } } };
const release = createPlayerTeardown([env.player("old", [card])], env);
const first = release();
await assert.rejects(first, error => error === original);
assert.equal(release(), first);
await assert.rejects(release(), error => error === original);
assert.equal(count, 1);
assert.equal(env._status.eventManager.eventStack.length, 0);
});
function battleHarness(env, prepareBattle, logger) {
const source = readFileSync(
new URL("../mengsan/mode.js", import.meta.url), "utf8");
const from = source.indexOf("const playBattle = ");
const to = source.indexOf("const setupBattle = ", from);
assert.ok(from >= 0 && to > from);
const saved = { runId: "r", revision: 1 }, log = [], prompts = [];
let commits = 0;
const makeFlow = (releaseSkills, releaseBattle) => createBattleFlow({
releaseSkills, releaseBattle,
quiesce: () => quiesceEngine(env._status), settlement: {
readRun: () => saved,
prepare: () => ({ id: "p", outcome: "victory", state: "chosen" }),
commit: () => { commits++; return { route: "map", run: saved }; },
}, showMap: () => null });
const diagnostics = logger || { error: (...args) => log.push(args) };
const parameters = { ...env, ui: {}, quiesceEngine,
createBattleSession, observeFreshEvent, makeFlow, diagnostics, prepareBattle,
createBattleResources: () => ({}),
reportFlowError: error => log.push(["reported", error]),
chooseButtons: async (...args) => {
assert.equal(env._status.eventManager.eventStack.length, 0);
prompts.push(args);
},
openModeSelection: async () => { log.push(["exit"]); },
document: { body: { classList: { remove() {} } } },
awaitSettlement: flow => flow.wait() };
const play = new Function(...Object.keys(parameters), `
let battleSequence = 0, activeBattle;
${source.slice(from, to)}
return {playBattle, getCurrent: () => activeBattle};`
)(...Object.values(parameters));
return { ...play, saved, log, prompts, get commits() { return commits; } };
}
for (const ignore_error of [true, false]) test(
`preparation failure exits: ignore_error=${ignore_error}`,
{ timeout: 2000 }, async () => {
const env = engine(ignore_error);
const original = new Error("missing battle character");
const harness = battleHarness(env, async () => { throw original; });
assert.equal(await harness.playBattle(
harness.saved, { id: "n" }, {}), undefined);
assert.equal(harness.getCurrent().engineError, original);
assert.equal(harness.getCurrent().session.active, false);
assert.equal(harness.prompts.length, 1);
assert.match(harness.prompts[0][2], /missing battle character/);
const record = harness.log.find(
entry => entry[0] === "battle.prepare.failed");
assert.equal(record?.[1], original);
assert.equal(record?.[2].sessionId, "r:1");
assert.deepEqual(harness.saved, { runId: "r", revision: 1 });
assert.equal(harness.commits, 0);
assert.equal(env._status.eventManager.eventStack.length, 0);
});
test("successful settlement commits once", { timeout: 2000 }, async () => {
const env = engine(); let harness;
harness = battleHarness(env, async () => { setImmediate(() => {
const current = harness.getCurrent();
current.flow.requestFinish({ session: current.session, hp: 10 });
current.finished();
}); });
const result = await harness.playBattle(harness.saved, { id: "n" }, {});
assert.equal(result.route, "map"); assert.equal(harness.commits, 1);
assert.deepEqual(harness.prompts, []); assert.deepEqual(harness.log, []);
});
for (const ignore_error of [true, false]) test(
`nested equipment error propagates: ignore_error=${ignore_error}`,
async () => {
const env = engine(ignore_error);
const original = new Error("child cleanup failed");
const card = { name: "equipment", info: { skills: [], clearLose: true,
async onLose() {
env.game.createEvent("child").setContent(async () => { throw original; });
} } };
await assert.rejects(
createPlayerTeardown([env.player("old", [card])], env)(),
error => error === original);
assert.equal(env._status.eventManager.eventStack.length, 0);
});
test("preparation failure writes a report with original stack", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "mengsan-diagnostics-"));
const storage = {
write: (name, text) => writeFile(path.join(directory, name), text),
list: async () => (await readdir(directory)).map(name => ({ name })),
remove: name => unlink(path.join(directory, name)),
status: () => ({ kind: "file", location: directory }),
};
const logger = createDiagnostics({ storage });
try {
const original = new Error("missing support role");
const harness = battleHarness(engine(), async () => { throw original; },
logger.scope("mengsan.mode"));
await harness.playBattle(harness.saved, { id: "node-1" }, {});
await logger.flush();
const reports = (await readdir(directory))
.filter(name => name.startsWith("report-"));
assert.equal(reports.length, 1);
const report = JSON.parse(
await readFile(path.join(directory, reports[0]), "utf8"));
assert.equal(report.failure.event, "battle.prepare.failed");
assert.equal(report.failure.module, "mengsan.mode");
assert.equal(report.failure.error.message, original.message);
assert.match(report.failure.error.stack, /test_battle_lifecycle/);
assert.equal(report.failure.context.nodeId, "node-1");
} finally {
logger.dispose();
for (const name of await readdir(directory)) {
await unlink(path.join(directory, name));
}
await rmdir(directory);
}
});
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createPlayerTeardown, removeBattleSkill } from "../mengsan/battle/skill-teardown.js";
import { createBattleFlow } from "../mengsan/battle/battle-flow.js";
function fixture(definitions, names) {
const calls = [];
const status = { eventManager: { eventStack: [], tempEvent: null, rootEvent: null } };
class GameEvent {
constructor() { this.name = "teardown"; }
setContent(fn) { this.content = fn; }
async waitNext() {}
clearStepCache() {}
async start() {
status.event = this;
status.eventManager.rootEvent = this;
status.eventManager.eventStack.push(this);
try { await this.content(); }
finally { status.eventManager.eventStack.pop(); delete status.event; }
}
}
const player = { name: "test", playerid: "p1", skills: names.slice(),
hiddenSkills: [], invisibleSkills: [], tempSkills: {}, additionalSkills: {}, storage: {},
unmarkSkill(name) { calls.push(["unmark", name]); }, checkConflict() {},
removeSkillTrigger(name) { calls.push(["trigger", name]); },
enableSkill() {}, removeAdditionalSkills() {} };
const env = { lib: { skill: definitions, element: { GameEvent } }, _status: status,
game: { broadcastAll(fn, p, name) {
for (const list of [p.skills, p.hiddenSkills, p.invisibleSkills,
...Object.values(p.additionalSkills)]) {
if (!list.remove) Object.defineProperty(list, "remove", { value(value) {
const index = this.indexOf(value); if (index >= 0) this.splice(index, 1);
} });
}
fn(p, name);
}, callHook() {} }, get: { itemtype: () => "unknown" } };
return { player, env, calls };
}
test("metadata-only protected skills do not block whole-player teardown", async () => {
const marker = { superCharlotte: true, charlotte: true, forced: true,
unique: true, popup: false, silent: true, ai: { threaten: 1 } };
const fixed = { fixed: true };
let removed = 0;
const f = fixture({ chaofeng_common: marker, fixedMarker: fixed,
normal: { async onremove() { removed++; } } },
["chaofeng_common", "fixedMarker", "normal"]);
const release = createPlayerTeardown([f.player], f.env);
await release(); await release();
assert.deepEqual(f.player.skills, ["chaofeng_common", "fixedMarker"]);
assert.equal(removed, 1);
assert.equal(f.env.lib.skill.chaofeng_common, marker);
assert.equal(f.env.lib.skill.fixedMarker, fixed);
});
test("protected callbacks and trigger registrations release once at player disposal", async () => {
let finished = false;
const info = { fixed: true, superCharlotte: true, trigger: { player: "damage" },
async onremove(player) { await Promise.resolve(); delete player.storage.state; finished = true; } };
const f = fixture({ active: info }, ["active"]);
f.player.storage.state = 1;
const release = createPlayerTeardown([f.player], f.env);
await release(); await release();
assert.equal(finished, true);
assert.deepEqual(f.player.skills, []);
assert.deepEqual(f.calls.filter(c => c[0] === "trigger"), [["trigger", "active"]]);
assert.equal(info.fixed, true); assert.equal(info.superCharlotte, true);
});
test("callback failure remains visible and cannot replay partial teardown", async () => {
let calls = 0;
const f = fixture({ active: { superCharlotte: true,
onremove() { calls++; throw new Error("callback failure"); } } }, ["active"]);
const release = createPlayerTeardown([f.player], f.env);
const first = release();
await assert.rejects(first, /callback failure/);
assert.equal(release(), first);
await assert.rejects(release(), /callback failure/);
assert.equal(calls, 1);
});
test("single skill removal keeps protected-skill safeguards", async () => {
const f = fixture({ active: { fixed: true, onremove() {} } }, ["active"]);
await assert.rejects(removeBattleSkill(f.player, "active", f.env), /protected skill/);
assert.deepEqual(f.player.skills, ["active"]);
});
test("protected mod skills are active and all player-owned sources are removed", async () => {
const f = fixture({ active: { superCharlotte: true, mod: { cardEnabled() {} } } }, ["active"]);
f.player.hiddenSkills.push("active"); f.player.invisibleSkills.push("active");
f.player.tempSkills.active = "phaseAfter"; f.player.additionalSkills.source = ["active"];
await createPlayerTeardown([f.player], f.env)();
assert.deepEqual(f.player.skills, []); assert.deepEqual(f.player.hiddenSkills, []);
assert.deepEqual(f.player.invisibleSkills, []); assert.deepEqual(f.player.tempSkills, {});
assert.deepEqual(f.player.additionalSkills, {});
});
test("string onremove drains discard before deleting card storage", async () => {
const f = fixture({ active: { fixed: true, onremove: "discard" } }, ["active"]);
const cards = [{}]; let discarded = false, thrown = false;
f.player.storage.active = cards; f.player.$throw = () => { thrown = true; };
f.env.get.itemtype = value => value === cards ? "cards" : "unknown";
f.env.game.cardsDiscard = async value => {
assert.equal(value, cards); assert.equal(f.player.storage.active, cards);
await Promise.resolve(); discarded = true;
};
await createPlayerTeardown([f.player], f.env)();
assert.equal(discarded, true); assert.equal(thrown, true);
assert.equal(f.player.storage.active, undefined);
});
test("protected mark cleanup is not mistaken for passive metadata", async () => {
let unmarked = false;
const f = fixture({ active: { superCharlotte: true,
intro: { onunmark() { unmarked = true; } } } }, ["active"]);
f.player.unmarkSkill = name => f.env.lib.skill[name].intro.onunmark();
await createPlayerTeardown([f.player], f.env)();
assert.equal(unmarked, true); assert.deepEqual(f.player.skills, []);
});
test("settlement reaches map once with a protected marker on the old player", async () => {
const f = fixture({ chaofeng_common: { superCharlotte: true, ai: { threaten: 1 } } },
["chaofeng_common"]);
let commits = 0, disposals = 0, maps = 0;
const run = { runId: "r", revision: 1 };
const session = { id: "s", requestStop() {}, async drain() {},
async dispose() { disposals++; } };
const flow = createBattleFlow({ settlement: { readRun: () => run,
async prepare() { return { id: "p", outcome: "victory", state: "chosen" }; },
async commit() { commits++; return { route: "map", run }; } },
quiesce: async () => {}, releaseSkills: createPlayerTeardown([f.player], f.env),
showMap() { maps++; return null; } });
await flow.enterBattle({ session, run, node: { id: "n" }, encounter: {}, start() {} });
flow.requestFinish({ session, hp: 10 });
await flow.wait();
assert.equal(flow.state, "map");
assert.equal(flow.settlementStatus.committed, true);
assert.deepEqual([commits, disposals, maps], [1, 1, 1]);
});
test("a real cleanup failure preserves the commit receipt and never reissues rewards", async () => {
const f = fixture({ active: { fixed: true, onremove() { throw new Error("release failed"); } } },
["active"]);
let commits = 0, maps = 0;
const run = { runId: "r", revision: 1 };
const session = { id: "s", requestStop() {}, async drain() {}, async dispose() {} };
const flow = createBattleFlow({ settlement: { readRun: () => run,
async prepare() { return { id: "p", outcome: "victory", state: "chosen" }; },
async commit() { commits++; return { route: "map", run }; } },
quiesce: async () => {}, releaseSkills: createPlayerTeardown([f.player], f.env),
showMap() { maps++; } });
await flow.enterBattle({ session, run, node: { id: "n" }, encounter: {}, start() {} });
flow.requestFinish({ session, hp: 10 });
await assert.rejects(flow.wait(), /release failed/);
assert.deepEqual(flow.settlementStatus, { committed: true, phase: "releaseSkills" });
await assert.rejects(flow.retry(), /release failed/);
assert.equal(commits, 1); assert.equal(maps, 0);
});