mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-23 00:00:00 +08:00
fixed # 修复梦三战斗异常及退场技能清理
This commit is contained in:
+9
-2
@@ -15,6 +15,8 @@ tools/*.mjs
|
|||||||
/Slay the Spire 2
|
/Slay the Spire 2
|
||||||
|
|
||||||
*.mjs
|
*.mjs
|
||||||
|
!tools/test_skill_teardown.mjs
|
||||||
|
!tools/test_battle_lifecycle.mjs
|
||||||
*.cjs
|
*.cjs
|
||||||
!mengsan/tools/check-sts2-relics.mjs
|
!mengsan/tools/check-sts2-relics.mjs
|
||||||
!mengsan/tools/write-sts2-relics-report.mjs
|
!mengsan/tools/write-sts2-relics-report.mjs
|
||||||
@@ -43,6 +45,11 @@ __pycache__/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
|
|
||||||
/.comet/
|
!/.comet/
|
||||||
/.comet/*
|
/.comet/*
|
||||||
/.comet/config.yaml
|
!/.comet/config.yaml
|
||||||
|
# >>> Comet managed project state >>>
|
||||||
|
!/.comet/
|
||||||
|
/.comet/*
|
||||||
|
!/.comet/config.yaml
|
||||||
|
# <<< Comet managed project state <<<
|
||||||
|
|||||||
@@ -39,6 +39,28 @@
|
|||||||
手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见
|
手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见
|
||||||
[手牌 UI 子扩展说明](docs/ui-hand-subextension.md)。
|
[手牌 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。
|
梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。
|
||||||
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
|
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
|
||||||
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
|
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMa
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
get state() { return state; },
|
get state() { return state; },
|
||||||
get error() { return error; },
|
get error() { return error; },
|
||||||
|
get settlementStatus() {
|
||||||
|
return { committed: !!job?.committed, phase };
|
||||||
|
},
|
||||||
async enterBattle({session, run, node, encounter, start}) {
|
async enterBattle({session, run, node, encounter, start}) {
|
||||||
if (state !== "map") throw new Error("Map handoff not ready");
|
if (state !== "map") throw new Error("Map handoff not ready");
|
||||||
const saved = settlement.readRun();
|
const saved = settlement.readRun();
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
// Candidate-only engine-derived release. Never replaces Player.prototype or lib.skill.
|
// 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) {
|
export async function removeBattleSkill(player, skill, {lib,game,get,_status}, wholePlayer = false) {
|
||||||
const names = Array.isArray(skill) ? skill.slice() : [skill];
|
const names = Array.isArray(skill) ? skill.slice() : [skill];
|
||||||
for (const name of names) {
|
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");
|
if (!wholePlayer && Object.values(player.additionalSkills || {}).some(list => list?.length)) throw new Error("Additional skill release adapter required");
|
||||||
const remove = async function removeSkill(skill) {
|
const remove = async function removeSkill(skill) {
|
||||||
@@ -21,7 +28,7 @@ export async function removeBattleSkill(player, skill, {lib,game,get,_status}, w
|
|||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
var info = lib.skill[skill];
|
var info = lib.skill[skill];
|
||||||
if (info?.fixed && arguments[1] !== true) {
|
if (info?.fixed && !wholePlayer) {
|
||||||
return skill;
|
return skill;
|
||||||
}
|
}
|
||||||
this.unmarkSkill(skill);
|
this.unmarkSkill(skill);
|
||||||
@@ -52,11 +59,11 @@ export async function removeBattleSkill(player, skill, {lib,game,get,_status}, w
|
|||||||
cards = [cards];
|
cards = [cards];
|
||||||
}
|
}
|
||||||
if (get.itemtype(cards) == "cards") {
|
if (get.itemtype(cards) == "cards") {
|
||||||
if (this.onremove == "discard") {
|
if (info.onremove == "discard") {
|
||||||
this.$throw(cards);
|
this.$throw(cards);
|
||||||
}
|
}
|
||||||
if (this.onremove == "discard" || this.onremove == "lose") {
|
if (info.onremove == "discard" || info.onremove == "lose") {
|
||||||
game.cardsDiscard(cards);
|
await game.cardsDiscard(cards);
|
||||||
delete this.storage[skill];
|
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");
|
if (!Array.isArray(releases) || releases.some(fn => typeof fn !== "function")) throw new TypeError("Explicit release callbacks required");
|
||||||
const previous = _status.eventManager.rootEvent;
|
const previous = _status.eventManager.rootEvent;
|
||||||
const root = new lib.element.GameEvent("mengsanSkillTeardown", false, _status.eventManager);
|
const root = new lib.element.GameEvent("mengsanSkillTeardown", false, _status.eventManager);
|
||||||
let releaseError;
|
let releaseError, releaseFailed = false;
|
||||||
root.setContent(async () => {
|
root.setContent(async () => {
|
||||||
try {
|
try {
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
@@ -97,6 +104,7 @@ export async function runSkillTeardown(releases, {lib,_status}) {
|
|||||||
await root.waitNext();
|
await root.waitNext();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
releaseFailed = true;
|
||||||
releaseError = error;
|
releaseError = error;
|
||||||
// Let the engine drain any queued work and pop this root normally.
|
// 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();
|
await root.start();
|
||||||
if (_status.eventManager.eventStack.includes(root)) throw new Error("Teardown event still active");
|
if (_status.eventManager.eventStack.includes(root)) throw new Error("Teardown event still active");
|
||||||
if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previous;
|
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.
|
// 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.
|
// Each callback runs at most once; failed/partially executed teardown is NOT replayed blindly.
|
||||||
export function createPlayerTeardown(players, env) {
|
export function createPlayerTeardown(players, env) {
|
||||||
const captured = players.slice();
|
const captured = players.slice();
|
||||||
|
const diagnostics = getDiagnostics().scope("mengsan.skillTeardown");
|
||||||
let operation;
|
let operation;
|
||||||
const names = player => [...new Set([
|
const names = player => [...new Set([
|
||||||
...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []),
|
...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []),
|
||||||
|
...(player.initedSkills || []),
|
||||||
...Object.keys(player.tempSkills || {}),
|
...Object.keys(player.tempSkills || {}),
|
||||||
...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []),
|
...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []),
|
||||||
])];
|
])];
|
||||||
@@ -122,18 +132,86 @@ export function createPlayerTeardown(players, env) {
|
|||||||
if (operation) return operation;
|
if (operation) return operation;
|
||||||
operation = (async () => {
|
operation = (async () => {
|
||||||
// Preflight ALL players before modifying any skill.
|
// Preflight ALL players before modifying any skill.
|
||||||
for (const player of captured) for (const name of names(player)) {
|
for (const player of captured) {
|
||||||
if (env.lib.skill[name]?.fixed || env.lib.skill[name]?.superCharlotte) throw new Error("Protected skill requires adapter: " + name);
|
|
||||||
if (Object.values(player.additionalSkills || {}).some(value => !Array.isArray(value))) throw new Error("Unsupported additional skill source shape");
|
if (Object.values(player.additionalSkills || {}).some(value => !Array.isArray(value))) throw new Error("Unsupported additional skill source shape");
|
||||||
}
|
}
|
||||||
await runSkillTeardown(captured.map(player => async () => {
|
await runSkillTeardown(captured.map(player => async () => {
|
||||||
const released = new Set();
|
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");
|
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);
|
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);
|
released.add(name);
|
||||||
|
equipmentSkills.delete(name);
|
||||||
// Children can add skills. Drain them before taking the next inventory.
|
// Children can add skills. Drain them before taking the next inventory.
|
||||||
await env._status.event.waitNext();
|
await env._status.event.waitNext();
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-6
@@ -681,6 +681,10 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
|||||||
run.player.permanentSkills.forEach(skill => {
|
run.player.permanentSkills.forEach(skill => {
|
||||||
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(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 [
|
for (const skill of [
|
||||||
"mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_ic_bridge_shuying", "mengsan_shared_bridge_shuying",
|
"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",
|
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_intent_refresh_shuying",
|
||||||
@@ -851,10 +855,18 @@ const awaitSettlement = async (flow, initial = flow.wait()) => {
|
|||||||
catch (error) {
|
catch (error) {
|
||||||
if (flow.state !== "retryable") throw error;
|
if (flow.state !== "retryable") throw error;
|
||||||
diagnostics.error("settlement.prompt", error, { flowState: flow.state });
|
diagnostics.error("settlement.prompt", error, { flowState: flow.state });
|
||||||
const action = await chooseButtons("结算未完成", [
|
const saved = flow.settlementStatus.committed;
|
||||||
{id:"retry",name:"重试保存与结算"},
|
const skillFailure = flow.settlementStatus.phase === "releaseSkills";
|
||||||
|
const actions = [
|
||||||
{id:"exit",name:"保留已保存进度并返回模式选择"},
|
{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 });
|
diagnostics.info("settlement.prompt.action", { action });
|
||||||
if (action === "exit") { await openModeSelection(); throw error; }
|
if (action === "exit") { await openModeSelection(); throw error; }
|
||||||
operation = flow.retry();
|
operation = flow.retry();
|
||||||
@@ -885,13 +897,27 @@ const playBattle = async (run, node, encounter) => {
|
|||||||
session.ownResource({}, () => {
|
session.ownResource({}, () => {
|
||||||
if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previousRoot;
|
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);
|
observeFreshEvent(session, root);
|
||||||
await flow.enterBattle({session,run,node,encounter,start() {
|
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.
|
await signal; // External controller, never an engine event content await.
|
||||||
if (current.engineError) {
|
if (current.engineFailed) {
|
||||||
reportFlowError(current.engineError);
|
reportFlowError(current.engineError);
|
||||||
session.requestStop();
|
session.requestStop();
|
||||||
current.handUI?.dispose();
|
current.handUI?.dispose();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user