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:
@@ -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 点伤害,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user