diff --git a/.gitignore b/.gitignore index a917d32..afeb102 100644 --- a/.gitignore +++ b/.gitignore @@ -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 <<< diff --git a/mengsan/README.md b/mengsan/README.md index b6065b1..47b59a6 100644 --- a/mengsan/README.md +++ b/mengsan/README.md @@ -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 点伤害, diff --git a/mengsan/battle/battle-flow.js b/mengsan/battle/battle-flow.js index 42ccf29..64a58fb 100644 --- a/mengsan/battle/battle-flow.js +++ b/mengsan/battle/battle-flow.js @@ -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(); diff --git a/mengsan/battle/skill-teardown.js b/mengsan/battle/skill-teardown.js index 9d08d5c..750965e 100644 --- a/mengsan/battle/skill-teardown.js +++ b/mengsan/battle/skill-teardown.js @@ -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(); } diff --git a/mengsan/mode.js b/mengsan/mode.js index 0e9cfb1..7eeed85 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -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(); diff --git a/tools/test_battle_lifecycle.mjs b/tools/test_battle_lifecycle.mjs new file mode 100644 index 0000000..748e563 --- /dev/null +++ b/tools/test_battle_lifecycle.mjs @@ -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); + } +}); diff --git a/tools/test_skill_teardown.mjs b/tools/test_skill_teardown.mjs new file mode 100644 index 0000000..42f66a4 --- /dev/null +++ b/tools/test_skill_teardown.mjs @@ -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); +});