mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-21 14:49:59 +08:00
fixed # 重构梦三项目结构
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
const camps = new Set(["ally", "enemy", "neutral"]);
|
||||
export const campPresets = Object.freeze({ ally: { enabled: true }, enemy: { enabled: true }, neutral: { enabled: false } });
|
||||
const events = new Set(["battleStart", "roundStart", "turnStart", "turnEnd", "state"]);
|
||||
const checks = new Set(["hp", "hand", "alive", "linked", "turnedOver", "camp"]);
|
||||
const numeric = value => Number.isInteger(value) && value >= 0 && value <= 99;
|
||||
const assert = (condition, message) => { if (!condition) throw new Error("梦三关卡配置:" + message); };
|
||||
|
||||
export function validateBattlePlan(plan) {
|
||||
assert(plan && Array.isArray(plan.units) && Array.isArray(plan.rules), "units/rules 必须为数组");
|
||||
assert(plan.units.length > 0 && plan.units.length <= 7 && plan.rules.length <= 32, "初始单位或规则数量越界");
|
||||
const ids = new Set(["player"]), ruleIds = new Set();
|
||||
const unit = spec => {
|
||||
assert(spec && typeof spec.id === "string" && /^[\w.-]+$/.test(spec.id) && !ids.has(spec.id), "单位 ID 无效或重复");
|
||||
ids.add(spec.id);
|
||||
assert(ids.size <= 8, "单场累计最多 8 个席位(含主角及后续援军)");
|
||||
assert(typeof spec.character === "string" && spec.character.length > 0 && camps.has(spec.camp), "武将或阵营无效");
|
||||
assert(spec.camp !== "neutral", "中立预设尚未启用");
|
||||
assert(spec.tier == null || ["normal", "elite", "boss"].includes(spec.tier), "怪物阶级无效");
|
||||
assert(spec.deck == null || (Array.isArray(spec.deck) && spec.deck.length > 0 && spec.deck.every(entry =>
|
||||
typeof entry === "string" && Boolean(entry) || entry && typeof entry.name === "string" && Boolean(entry.name) &&
|
||||
(entry.suit == null || ["spade", "heart", "club", "diamond"].includes(entry.suit)) &&
|
||||
(entry.number == null || Number.isInteger(entry.number) && entry.number >= 1 && entry.number <= 13))), "怪物专属牌堆无效");
|
||||
for (const key of ["hand", "hp", "maxHp"]) if (spec[key] != null) assert(numeric(spec[key]), key + " 越界");
|
||||
if (spec.hp != null) assert(spec.hp > 0, "初始体力必须大于 0");
|
||||
if (spec.maxHp != null) assert(spec.maxHp > 0 && (spec.hp == null || spec.hp <= spec.maxHp), "初始体力上限无效");
|
||||
assert(spec.inheritSkills == null || typeof spec.inheritSkills === "boolean", "inheritSkills 必须为布尔值");
|
||||
assert(spec.after == null || typeof spec.after === "string", "after 必须是单位 ID");
|
||||
assert(spec.equipment == null || (Array.isArray(spec.equipment) && spec.equipment.length <= 5), "equipment 无效");
|
||||
for (const card of spec.equipment || []) {
|
||||
assert(card && typeof card.name === "string" && card.name.length > 0, "初始装备名称无效");
|
||||
assert(["spade", "heart", "club", "diamond"].includes(card.suit), "初始装备花色无效");
|
||||
assert(Number.isInteger(card.number) && card.number >= 1 && card.number <= 13, "初始装备点数无效");
|
||||
}
|
||||
assert(spec.skills == null || (Array.isArray(spec.skills) && spec.skills.every(s => typeof s === "string")), "skills 无效");
|
||||
};
|
||||
plan.units.forEach(unit);
|
||||
for (const rule of plan.rules) {
|
||||
assert(rule && typeof rule.id === "string" && !ruleIds.has(rule.id), "规则 ID 重复或无效"); ruleIds.add(rule.id);
|
||||
assert(rule.when && events.has(rule.when.event || "state"), "触发时机无效");
|
||||
assert(rule.blocksVictory == null || typeof rule.blocksVictory === "boolean", "blocksVictory 必须为布尔值");
|
||||
assert(rule.when.round == null || (numeric(rule.when.round) && rule.when.round > 0), "轮数无效");
|
||||
assert(rule.when.actorTurns == null || (rule.when.event === "turnEnd" && rule.when.actor && numeric(rule.when.actorTurns) && rule.when.actorTurns > 0), "actorTurns 仅用于指定角色的回合结束");
|
||||
assert(rule.when.turn == null || (numeric(rule.when.turn) && rule.when.turn > 0), "回合数无效");
|
||||
assert(Array.isArray(rule.effects) && rule.effects.length > 0 && rule.effects.length <= 16, "效果列表无效");
|
||||
for (const effect of rule.effects) {
|
||||
assert(["spawn", "draw", "recover", "maxHp", "skill", "camp", "dialogue", "discardEquipment"].includes(effect.type), "未知效果");
|
||||
if (effect.type === "spawn") unit(effect.unit);
|
||||
else if (effect.type === "dialogue") {
|
||||
assert(Array.isArray(effect.lines) && effect.lines.every(line => line && ["character", "narrator", "sound", "choice"].includes(line.type) && typeof line.text === "string" && line.text.trim() && (line.type !== "character" || typeof line.character === "string") && (line.type !== "choice" || (/^story\.[\w.]+$/.test(line.flag || "") && Array.isArray(line.choices) && line.choices.length >= 1 && (line.defaultChoice == null || line.choices.some(choice => choice.id === line.defaultChoice))))), "dialogue 台词无效");
|
||||
}
|
||||
else {
|
||||
assert(typeof effect.target === "string", "效果缺少目标 ID");
|
||||
if (["draw", "recover", "maxHp"].includes(effect.type)) assert(numeric(effect.amount) && effect.amount > 0, "效果数值无效");
|
||||
if (effect.type === "discardEquipment") assert(typeof effect.name === "string" && effect.name.length > 0, "弃置装备名称无效");
|
||||
if (effect.type === "skill") assert(typeof effect.skill === "string" && effect.skill.length > 0, "技能 ID 无效");
|
||||
if (effect.type === "camp") assert(["ally", "enemy"].includes(effect.camp) && effect.target !== "player", "阵营无效或试图改变主角阵营");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rule of plan.rules) {
|
||||
assert(rule.when.actor == null || ids.has(rule.when.actor), "行动角色 ID 无效");
|
||||
assert(rule.when.all == null || (Array.isArray(rule.when.all) && rule.when.all.length <= 16), "条件列表无效");
|
||||
for (const condition of rule.when.all || []) {
|
||||
assert(ids.has(condition.unit) && checks.has(condition.field), "条件单位或字段无效");
|
||||
if (["hp", "hand"].includes(condition.field)) assert(["eq", "gte", "lte"].includes(condition.op) && numeric(condition.value), "数值比较无效");
|
||||
else if (condition.field === "camp") assert(camps.has(condition.value), "阵营条件无效");
|
||||
else assert(typeof condition.value === "boolean", "状态条件必须为布尔值");
|
||||
}
|
||||
for (const effect of rule.effects) if (!["spawn", "dialogue"].includes(effect.type)) assert(ids.has(effect.target), "效果目标不存在");
|
||||
}
|
||||
for (const spec of [...plan.units, ...plan.rules.flatMap(r => r.effects.filter(e => e.type === "spawn").map(e => e.unit))]) {
|
||||
assert(spec.after == null || (ids.has(spec.after) && spec.after !== spec.id), "after 席位目标无效");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function grantSupport(run, rewardId, spec) {
|
||||
const supports = run.player.supports ||= [];
|
||||
const battles = spec.battles ?? -1;
|
||||
assert(battles === -1 || (Number.isInteger(battles) && battles > 0 && battles <= 99), "支援持续场数无效");
|
||||
// One support per source; repeated permanent items do not clone the same helper.
|
||||
const existing = supports.find(s => s.rewardId === rewardId);
|
||||
if (existing) { existing.battles = existing.battles === -1 || battles === -1 ? -1 : Math.min(99, existing.battles + battles); return; }
|
||||
const number = (run.supportSequence || 0) + 1;
|
||||
run.supportSequence = number;
|
||||
supports.push({ id: `support_${number}`, rewardId, category: spec.category || "item", unit: copy(spec.unit), battles });
|
||||
}
|
||||
|
||||
export function buildBattlePlan(encounter, run) {
|
||||
const plan = copy(encounter.battlePlan || {
|
||||
units: [{ id: "enemy_1", character: encounter.enemy, camp: "enemy", tier: encounter.tier || "normal", hand: 4 }], rules: [],
|
||||
});
|
||||
plan.rules ||= [];
|
||||
// Consumed only in the battle's run snapshot; the existing settlement commits it atomically.
|
||||
for (const support of run.player.supports || []) {
|
||||
if (support.battles === 0) continue;
|
||||
assert(support.battles === -1 || (Number.isInteger(support.battles) && support.battles > 0), "支援持续场数无效");
|
||||
plan.units.push({ ...copy(support.unit), id: support.id });
|
||||
}
|
||||
validateBattlePlan(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function consumeSupports(run) {
|
||||
run.player.supports = (run.player.supports || []).map(s => ({ ...s, battles: s.battles > 0 ? s.battles - 1 : s.battles })).filter(s => s.battles !== 0);
|
||||
}
|
||||
|
||||
export function createBattleDirector(plan, adapters) {
|
||||
validateBattlePlan(plan);
|
||||
const units = new Map(), fired = new Set(), completedTurns = new Map(), seenPhases = new WeakSet();
|
||||
let round = 0, turn = 0, actor = null, ready = false, running = false, disposed = false;
|
||||
const compare = (actual, op, value) => op === "gte" ? actual >= value : op === "lte" ? actual <= value : actual === value;
|
||||
function matches(when, event) {
|
||||
if ((when.event || "state") !== event) return false;
|
||||
if (when.round != null && round < when.round) return false;
|
||||
if (when.turn != null && turn < when.turn) return false;
|
||||
if (when.actorTurns != null && (completedTurns.get(actor) || 0) < when.actorTurns) return false;
|
||||
if (when.actor != null && units.get(when.actor) !== actor) return false;
|
||||
return (when.all || []).every(condition => {
|
||||
const player = units.get(condition.unit);
|
||||
if (!player) return false;
|
||||
const state = adapters.state(player);
|
||||
return compare(state[condition.field], condition.op || "eq", condition.value);
|
||||
});
|
||||
}
|
||||
async function evaluate(event = "state") {
|
||||
if (!ready || disposed || running) return;
|
||||
running = true;
|
||||
try {
|
||||
// Every rule runs once per battle; marking before effects prevents recursive triggers.
|
||||
for (let pass = 0; pass <= plan.rules.length; pass++) {
|
||||
let changed = false;
|
||||
for (const rule of plan.rules) {
|
||||
if (disposed || !adapters.active()) return;
|
||||
if (fired.has(rule.id) || !matches(rule.when, event)) continue;
|
||||
fired.add(rule.id); changed = true;
|
||||
for (const effect of rule.effects) {
|
||||
if (disposed || !adapters.active()) return;
|
||||
if (effect.type === "dialogue") await adapters.dialogue(effect.lines);
|
||||
else if (effect.type === "spawn") {
|
||||
const anchor = effect.unit.after == null ? null : units.get(effect.unit.after);
|
||||
if (effect.unit.after != null && !anchor) throw new Error("支援席位目标尚未登场:" + effect.unit.after);
|
||||
units.set(effect.unit.id, await adapters.spawn(effect.unit, anchor));
|
||||
}
|
||||
else {
|
||||
const target = units.get(effect.target);
|
||||
if (target && adapters.state(target).alive) await adapters.effect(target, effect);
|
||||
}
|
||||
}
|
||||
adapters.log(rule.name || rule.id);
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
} finally { running = false; }
|
||||
}
|
||||
return {
|
||||
bind(id, player) { if (units.has(id)) throw new Error("Duplicate battle unit: " + id); units.set(id, player); },
|
||||
async start() { ready = true; await evaluate("battleStart"); await evaluate("state"); },
|
||||
async beforeTurn(player) {
|
||||
turn++; actor = player;
|
||||
if (player === units.get("player")) { round++; await evaluate("roundStart"); }
|
||||
await evaluate("turnStart"); await evaluate("state");
|
||||
},
|
||||
async afterTurn(player, phase) {
|
||||
if (!ready || disposed || !adapters.active()) return;
|
||||
if (phase && seenPhases.has(phase)) return;
|
||||
if (phase) seenPhases.add(phase);
|
||||
actor = player;
|
||||
completedTurns.set(player, (completedTurns.get(player) || 0) + 1);
|
||||
await evaluate("turnEnd"); await evaluate("state");
|
||||
},
|
||||
evaluate,
|
||||
get ready() { return ready; },
|
||||
get busy() { return running; },
|
||||
get pendingEnemies() { return plan.rules.some(rule => rule.blocksVictory && !fired.has(rule.id) && rule.effects.some(e => (e.type === "spawn" && e.unit.camp === "enemy") || (e.type === "camp" && e.camp === "enemy"))); },
|
||||
get defeatedEnemies() { return [...units.values()].filter(p => { const s = adapters.state(p); return s.camp === "enemy" && !s.alive; }).length; },
|
||||
dispose() { disposed = true; units.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Candidate external coordinator. Finish callbacks return a boolean, NEVER a drain promise.
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
export function createBattleFlow({settlement, chooseReward, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) {
|
||||
let state = "map", active = null, work = null, job = null, error = null;
|
||||
async function drive() {
|
||||
try {
|
||||
state = "draining";
|
||||
await active.session.drain();
|
||||
// Allow mode-start/parent frames and foreign pauses to unwind; never resume them.
|
||||
await quiesce();
|
||||
if (!job.pending) job.pending = await settlement.prepare(job.input);
|
||||
if (job.pending.outcome === "victory" && !job.victoryPresented) {
|
||||
await presentVictory(copy(job.pending));
|
||||
job.victoryPresented = true;
|
||||
}
|
||||
if (job.pending.state !== "chosen") {
|
||||
state = "choosing";
|
||||
if (!job.hasChoice) {
|
||||
job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss });
|
||||
if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
|
||||
job.hasChoice = true;
|
||||
}
|
||||
job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId);
|
||||
}
|
||||
if (!job.committed) {
|
||||
state = "committing";
|
||||
job.committed = await settlement.commit(job.input.run.runId, job.pending.id);
|
||||
}
|
||||
if (!job.skillsReleased) {
|
||||
state = "releasingSkills";
|
||||
await releaseSkills();
|
||||
job.skillsReleased = true;
|
||||
await quiesce();
|
||||
}
|
||||
state = "cleaning";
|
||||
await active.session.dispose(); // Failed cleanup retry does NOT replay a committed reward.
|
||||
await quiesce();
|
||||
if (!job.delivered) {
|
||||
state = "presenting";
|
||||
if (job.committed.route === "map") job.nextNode = await showMap(copy(job.committed.run));
|
||||
else await showEnding(job.committed.route, copy(job.committed.run));
|
||||
job.delivered = true;
|
||||
}
|
||||
state = job.committed.route === "map" ? "map" : "ended";
|
||||
return {...copy(job.committed), nextNode:copy(job.nextNode ?? null)};
|
||||
} catch (cause) {
|
||||
error = cause; state = "retryable"; throw cause;
|
||||
}
|
||||
}
|
||||
function launch() {
|
||||
error = null; state = "draining";
|
||||
work = Promise.resolve().then(drive);
|
||||
// The engine callback is fire-and-forget; keep failure observable without unhandled rejection.
|
||||
work.catch(() => {});
|
||||
}
|
||||
return Object.freeze({
|
||||
get state() { return state; },
|
||||
get error() { return error; },
|
||||
async enterBattle({session, run, node, encounter, start}) {
|
||||
if (state !== "map") throw new Error("Map handoff not ready");
|
||||
if (run.battleFlow?.pending) throw new Error("Resume pending settlement before battle");
|
||||
const saved = settlement.readRun();
|
||||
if (!saved || saved.runId !== run.runId || saved.revision !== run.revision) throw new Error("Stale battle input");
|
||||
state = "starting";
|
||||
try {
|
||||
await quiesce();
|
||||
active = {session, run, node:copy(node), encounter:copy(encounter)};job = null;work = null;
|
||||
// start MUST schedule only; it must not await the battle root's completion.
|
||||
state = "battle"; start();
|
||||
} catch (cause) { state = "blocked"; error = cause; throw cause; }
|
||||
},
|
||||
requestFinish({session, hp, outcome = "victory", defeatedEnemies = 1}) {
|
||||
if (state !== "battle" || session !== active.session) return false;
|
||||
if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown outcome");
|
||||
if (!Number.isInteger(defeatedEnemies) || defeatedEnemies < 0) throw new Error("Invalid defeated enemy count");
|
||||
job = {input:{run:copy(active.run),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}};
|
||||
state = "stopping";
|
||||
active.session.requestStop();
|
||||
launch();
|
||||
return true; // No game.pause/resume: stop loop, let current body unwind, then open rewards.
|
||||
},
|
||||
resumePending({session}) {
|
||||
if (state !== "map") throw new Error("Cannot resume during another operation");
|
||||
const run = settlement.readRun(), item = run?.battleFlow?.pending;
|
||||
if (!item) throw new Error("No durable pending settlement");
|
||||
active = {session};
|
||||
job = {input:{run}, pending:copy(item)};
|
||||
state = "stopping"; session.requestStop(); launch(); return work;
|
||||
},
|
||||
retry() {
|
||||
if (state !== "retryable") throw new Error("No failed settlement to retry");
|
||||
launch(); return work;
|
||||
},
|
||||
wait() { if (!work) throw new Error("No settlement requested"); return work; },
|
||||
});
|
||||
}
|
||||
|
||||
// Run outside engine events. No busy poll, no global queue clearing, no pause ownership guessing.
|
||||
export async function quiesceEngine({eventManager, pauseManager}, nextTask = () => new Promise(resolve => setTimeout(resolve, 0))) {
|
||||
await nextTask();
|
||||
await pauseManager.waitPause();
|
||||
if (eventManager.eventStack.length || eventManager.tempEvent) throw new Error("Engine event stack is not idle");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Track only the turn created by this battle's cooperative loop.
|
||||
const battleTurns = new WeakMap();
|
||||
const turnStages = new Set(["phase", "phaseZhunbei", "phaseJudge", "phaseDraw", "phaseUse", "phaseDiscard", "phaseJieshu"]);
|
||||
|
||||
export function stopBattleTurn(session, eventManager) {
|
||||
const turn = battleTurns.get(session);
|
||||
if (!turn) return;
|
||||
const belongsToTurn = event => {
|
||||
const seen = new Set();
|
||||
while (event && !seen.has(event)) {
|
||||
if (event === turn) return true;
|
||||
seen.add(event);
|
||||
event = event.parent;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Finish phase controllers, not the card/damage/death events currently resolving.
|
||||
// Those events must unwind before the session can safely dispose players.
|
||||
for (const event of eventManager.eventStack.slice().reverse()) {
|
||||
if (turnStages.has(event.name) && belongsToTurn(event)) event.finish();
|
||||
}
|
||||
turn.finish();
|
||||
}
|
||||
|
||||
// Candidate derived from local engine phaseLoop; no global replacement.
|
||||
export async function runPhaseLoop(event, player, session, { game, lib, _status, get, beforeTurn = async () => {} }) {
|
||||
let num = 1;
|
||||
let current = player;
|
||||
while (session.active && !event.finished && current.getSeatNum() === 0) {
|
||||
current.setSeatNum(num);
|
||||
current = current.next;
|
||||
num++;
|
||||
}
|
||||
while (session.active && !event.finished) {
|
||||
if (game.players.includes(event.player)) {
|
||||
await beforeTurn(event.player);
|
||||
if (!session.active || event.finished) return;
|
||||
lib.onphase.forEach((i) => i());
|
||||
if (!session.active || event.finished) return;
|
||||
const phase = event.player.phase();
|
||||
battleTurns.set(session, phase);
|
||||
try {
|
||||
event.next.remove(phase);
|
||||
let isRoundEnd = false;
|
||||
if (lib.onround.every((i) => i(phase, event.player))) {
|
||||
isRoundEnd = _status.roundSkipped;
|
||||
if (_status.isRoundFilter) {
|
||||
isRoundEnd = _status.isRoundFilter(phase, event.player);
|
||||
} else if (_status.seatNumSettled) {
|
||||
const seatNum = event.player.getSeatNum();
|
||||
if (seatNum != 0) {
|
||||
if (get.itemtype(_status.lastPhasedPlayer) != "player" || seatNum < _status.lastPhasedPlayer.getSeatNum()) {
|
||||
isRoundEnd = true;
|
||||
}
|
||||
}
|
||||
} else if (event.player == _status.roundStart) {
|
||||
isRoundEnd = true;
|
||||
}
|
||||
if (isRoundEnd && _status.globalHistory.some((i) => i.isRound)) {
|
||||
game.log();
|
||||
await event.trigger("roundEnd");
|
||||
}
|
||||
}
|
||||
if (!session.active || event.finished) { phase.finish(); return; }
|
||||
event.next.push(phase);
|
||||
await phase;
|
||||
} finally {
|
||||
if (battleTurns.get(session) === phase) battleTurns.delete(session);
|
||||
}
|
||||
}
|
||||
if (!session.active || event.finished) return;
|
||||
await event.trigger("phaseOver");
|
||||
if (!session.active || event.finished) return;
|
||||
const findNext = (current2) => {
|
||||
const players = game.players.slice(0).concat(game.dead).sort((a, b) => parseInt(a.dataset.position) - parseInt(b.dataset.position));
|
||||
const position = parseInt(current2.dataset.position);
|
||||
for (const player2 of players) {
|
||||
if (parseInt(player2.dataset.position) > position) {
|
||||
return player2;
|
||||
}
|
||||
}
|
||||
return players[0];
|
||||
};
|
||||
event.player = findNext(event.player);
|
||||
if (!event.player) return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Exclusive battle resources. Never clear arbitrary global queues or arbitrary timers.
|
||||
export function createBattleResources(session, {game, ui, _status}) {
|
||||
const cards = new Set(), nodes = new Map(), fields = [];
|
||||
const containers = () => [ui.arena, ui.control, ui.sidebar, ui.cardPile, ui.discardPile, ui.ordering, ui.special].filter(Boolean);
|
||||
const children = () => new Map(containers().map(parent => [parent, new Set(parent.childNodes)]));
|
||||
const before = children();
|
||||
const uiKeys = ["me","handcards1Container","handcards2Container","handcards1","handcards2"];
|
||||
const uiBefore = new Map(uiKeys.map(key => [key,Object.getOwnPropertyDescriptor(ui,key)]));
|
||||
function field(object, key, value) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
||||
object[key] = value;
|
||||
fields.push({object,key,value,descriptor});
|
||||
return value;
|
||||
}
|
||||
// Install before root.start so global history cannot retain the finished battle tree.
|
||||
field(_status, "globalHistory", [{cardMove:[],custom:[],useCard:[],changeHp:[],everything:[]}]);
|
||||
field(_status, "cardtag", Object.fromEntries(Object.keys(_status.cardtag || {}).map(key => [key,[]])));
|
||||
const counters = {phaseNumber:game.phaseNumber, roundNumber:game.roundNumber};
|
||||
game.phaseNumber = 0; game.roundNumber = 0;
|
||||
session.ownResource({}, () => {
|
||||
for (const card of cards) card.remove();
|
||||
for (const [node,parent] of nodes) if (node.parentNode === parent) node.remove();
|
||||
for (const {object,key,value,descriptor} of fields.reverse()) {
|
||||
if (object[key] !== value) continue; // A replacement owned by another subsystem survives.
|
||||
if (descriptor) Object.defineProperty(object,key,descriptor); else delete object[key];
|
||||
}
|
||||
game.phaseNumber = counters.phaseNumber; game.roundNumber = counters.roundNumber;
|
||||
});
|
||||
return Object.freeze({
|
||||
field,
|
||||
card(card) { cards.add(card); return card; },
|
||||
capturePreparedArena() {
|
||||
for (const key of uiKeys) {
|
||||
const descriptor = uiBefore.get(key);
|
||||
if (ui[key] !== descriptor?.value) fields.push({object:ui,key,value:ui[key],descriptor});
|
||||
}
|
||||
// Call synchronously after prepareArena, with onfree already drained.
|
||||
for (const parent of containers()) {
|
||||
const prior = before.get(parent) || new Set();
|
||||
for (const node of parent.childNodes) if (!prior.has(node)) {
|
||||
if ([ui.cardPile,ui.discardPile,ui.ordering,ui.special].includes(parent)) cards.add(node);
|
||||
else nodes.set(node,parent);
|
||||
}
|
||||
}
|
||||
},
|
||||
bindPlayers(players) {
|
||||
const owned = new Set(players);
|
||||
session.ownResource({}, () => {
|
||||
for (const key of ["currentPhase","lastPhasedPlayer","roundStart"]) {
|
||||
if (owned.has(_status[key])) delete _status[key];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Candidate only. No engine globals, event prototype patches, or save writes.
|
||||
const owners = new WeakMap();
|
||||
|
||||
export function createBattleSession(id) {
|
||||
if (typeof id !== "string" || !id.trim()) throw new TypeError("Session ID required");
|
||||
const token = Object.freeze({ id });
|
||||
let state = "running", disposePromise = null;
|
||||
const pending = new Set(), events = new Set(), resources = new Map(), errors = [];
|
||||
const waiters = new Set();
|
||||
const notify = () => { for (const wake of waiters) wake(); waiters.clear(); };
|
||||
const ensureRunning = () => { if (state !== "running") throw new Error("Session is stopping"); };
|
||||
const claim = object => {
|
||||
if (!object || (typeof object !== "object" && typeof object !== "function")) throw new TypeError("Object required");
|
||||
const owner = owners.get(object);
|
||||
if (owner && owner !== token) throw new Error("Foreign session resource");
|
||||
owners.set(object, token);
|
||||
};
|
||||
// Observe a native completion promise; never assimilate GameEvent.then(), which can start queues.
|
||||
const observe = promise => {
|
||||
if (!(promise instanceof Promise)) throw new TypeError("Native completion Promise required");
|
||||
pending.add(promise);
|
||||
promise.then(() => { pending.delete(promise); notify(); }, error => {
|
||||
errors.push(error); pending.delete(promise); notify();
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const api = {
|
||||
id,
|
||||
get state() { return state; },
|
||||
get active() { return state === "running"; },
|
||||
get pendingCount() { return pending.size; },
|
||||
guard(callback) {
|
||||
if (typeof callback !== "function") throw new TypeError("Callback required");
|
||||
return (...args) => state === "running" ? callback(...args) : undefined;
|
||||
},
|
||||
runTask(factory) {
|
||||
ensureRunning();
|
||||
if (typeof factory !== "function") throw new TypeError("Task factory required");
|
||||
// Reserve before invoking user code so a stop request inside it cannot race drain().
|
||||
return observe(Promise.resolve().then(() => state === "running" ? factory(api) : undefined));
|
||||
},
|
||||
ownEvent(event, completion) {
|
||||
ensureRunning();
|
||||
if (typeof event?.finish !== "function" || !(completion instanceof Promise)) throw new TypeError("Event and native completion required");
|
||||
claim(event);
|
||||
if (events.has(event)) throw new Error("Event already registered");
|
||||
events.add(event); observe(completion);
|
||||
return event;
|
||||
},
|
||||
ownResource(resource, release) {
|
||||
ensureRunning();
|
||||
if (typeof release !== "function") throw new TypeError("Release function required");
|
||||
claim(resource);
|
||||
if (resources.has(resource)) throw new Error("Resource already registered");
|
||||
resources.set(resource, release);
|
||||
return resource;
|
||||
},
|
||||
requestStop() {
|
||||
if (state !== "running") return false;
|
||||
state = "stopping";
|
||||
// Only explicitly registered events. Do not erase global next/after or guess ownership.
|
||||
for (const event of events) {
|
||||
try { event.finish(); } catch (error) { errors.push(error); }
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
async drain() {
|
||||
if (state === "running") throw new Error("requestStop required before drain");
|
||||
// Must be awaited by an external coordinator, not from a task tracked by this session.
|
||||
while (pending.size) await new Promise(resolve => waiters.add(resolve));
|
||||
if (errors.length) throw new AggregateError(errors, "Battle tasks failed; cleanup blocked");
|
||||
if (state === "stopping") state = "drained";
|
||||
},
|
||||
dispose() {
|
||||
if (state === "disposed") return Promise.resolve();
|
||||
if (disposePromise) return disposePromise;
|
||||
if (state !== "drained") return Promise.reject(new Error("Drain must complete before disposal"));
|
||||
state = "disposing";
|
||||
disposePromise = (async () => {
|
||||
// Reverse acquisition order; stop at first failure to preserve dependent resources.
|
||||
for (const [resource, release] of [...resources].reverse()) {
|
||||
await release(resource);
|
||||
resources.delete(resource); owners.delete(resource);
|
||||
}
|
||||
for (const event of events) owners.delete(event);
|
||||
events.clear(); state = "disposed";
|
||||
})().catch(error => { state = "drained"; throw error; }).finally(() => { disposePromise = null; });
|
||||
return disposePromise;
|
||||
},
|
||||
};
|
||||
return Object.freeze(api);
|
||||
}
|
||||
|
||||
// A reused ID must never cause old-session cleanup to delete the new player's entry.
|
||||
export function ownPlayerIndex(session, player, playerMap) {
|
||||
const id = player.playerid;
|
||||
if (!id || playerMap[id] !== player) throw new Error("Player must be registered first");
|
||||
return session.ownResource(player, () => { if (playerMap[id] === player) delete playerMap[id]; });
|
||||
}
|
||||
|
||||
// Engine pause booleans are not ownership tokens. Accept a lease from a coordinator instead.
|
||||
export function ownPauseLease(session, lease) {
|
||||
if (typeof lease?.release !== "function") throw new TypeError("Pause lease required");
|
||||
return session.ownResource(lease, () => lease.release());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]);
|
||||
export function createBattleSettlement({store, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct, now = Date.now}) {
|
||||
const readRun = () => store.read()[config.saveKey];
|
||||
function current(storage, runId) {
|
||||
const run = storage[config.saveKey];
|
||||
if (!run || run.runId !== runId) throw new Error("Stale run: settlement refused");
|
||||
run.battleFlow ||= { receipts: [], pending: null };
|
||||
return run;
|
||||
}
|
||||
function pending(storage, runId, id) {
|
||||
const run = current(storage, runId), item = run.battleFlow.pending;
|
||||
if (!item || item.id !== id) throw new Error("Settlement checkpoint mismatch");
|
||||
return {run, item};
|
||||
}
|
||||
return Object.freeze({
|
||||
readRun,
|
||||
// Persist fixed candidates before opening choice UI. No mutation of the live run.
|
||||
async prepare({run: snapshot, node, encounter, hp, outcome = "victory"}) {
|
||||
if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown battle outcome");
|
||||
if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.skipRandomReward && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result");
|
||||
const id = idFor(snapshot, node);
|
||||
return store.update(storage => {
|
||||
const run = current(storage, snapshot.runId);
|
||||
if (run.battleFlow.receipts.includes(id)) throw new Error("Battle already settled");
|
||||
if (run.battleFlow.pending) {
|
||||
if (run.battleFlow.pending.id !== id) throw new Error("Another settlement is pending");
|
||||
return run.battleFlow.pending;
|
||||
}
|
||||
if (run.actIndex !== snapshot.actIndex || run.revision !== snapshot.revision ||
|
||||
run.map?.nodes.find(n => n.id === node.id)?.completed !== false) {
|
||||
throw new Error("Stale node or revision");
|
||||
}
|
||||
// Battle may have changed RNG/card state since the last checkpoint.
|
||||
const base = copy(snapshot); delete base.battleFlow;
|
||||
if (outcome === "victory") {
|
||||
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
|
||||
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
|
||||
}
|
||||
const rewards = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : [];
|
||||
if (outcome === "victory" && !encounter.skipRandomReward && !rewards.length) throw new Error("Empty battle reward pool");
|
||||
// Persist the configured amount as a candidate, never as automatic victory income.
|
||||
const choices = outcome === "victory" && !encounter.skipRandomReward
|
||||
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
|
||||
run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome};
|
||||
return run.battleFlow.pending;
|
||||
});
|
||||
},
|
||||
async choose(runId, id, choiceId) {
|
||||
return store.update(storage => {
|
||||
const {item} = pending(storage, runId, id);
|
||||
if (item.state === "chosen") {
|
||||
if (item.choiceId !== choiceId) throw new Error("Choice already locked");
|
||||
return item;
|
||||
}
|
||||
const result = copy(item.base);
|
||||
let route = "defeat";
|
||||
if (item.outcome === "victory") {
|
||||
const choice = item.choices.find(c => c.id === choiceId);
|
||||
if (item.choices.length && choiceId !== null) {
|
||||
if (!choice) throw new Error("Reward is not a fixed candidate");
|
||||
if (choice.kind === "gold") {
|
||||
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
|
||||
result.player.gold += choice.amount;
|
||||
result.statistics.goldEarned += choice.amount;
|
||||
} else applyReward(result, choice.effectId || choice.id);
|
||||
} else if (choiceId !== null) throw new Error("Unexpected reward selection");
|
||||
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||
if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed");
|
||||
route = item.boss && !enterNextAct(result) ? "victory" : "map";
|
||||
} else result.status = "failed";
|
||||
// Save the exact result (including random card ID/target) before final commit.
|
||||
item.state = "chosen"; item.choiceId = choiceId; item.result = result;
|
||||
item.route = route; item.completedAt = now();
|
||||
return item;
|
||||
});
|
||||
},
|
||||
async commit(runId, id) {
|
||||
return store.update(storage => {
|
||||
const saved = storage[config.saveKey];
|
||||
const prior = storage[config.profileKey]?.completedRuns?.find(r => r.runId === runId && r.settlementId === id);
|
||||
if (!saved && prior) return {route:prior.outcome, run:null, duplicate:true};
|
||||
if (saved?.runId === runId && saved.battleFlow?.receipts?.includes(id)) return {route:"map", run:saved, duplicate:true};
|
||||
const {run, item} = pending(storage, runId, id);
|
||||
if (item.state !== "chosen") throw new Error("Durable chosen result required");
|
||||
const result = copy(item.result);
|
||||
result.battleFlow = {receipts:[...run.battleFlow.receipts, id], pending:null};
|
||||
if (item.route === "map") storage[config.saveKey] = result;
|
||||
else {
|
||||
const profile = storage[config.profileKey] ||= {wins:0, completedRuns:[]};
|
||||
profile.completedRuns ||= [];
|
||||
if (!profile.completedRuns.some(r => r.runId === runId)) {
|
||||
if (item.route === "victory") profile.wins = (profile.wins || 0) + 1;
|
||||
profile.completedRuns.push({runId, settlementId:id, outcome:item.route, character:result.player.character,
|
||||
completedNodes:result.statistics.completedNodes, goldEarned:result.statistics.goldEarned, completedAt:item.completedAt});
|
||||
}
|
||||
delete storage[config.saveKey]; // Same transaction as profile; never a second clear write.
|
||||
}
|
||||
return {route:item.route, run:result, duplicate:false};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 梦三独有的出牌经济,不修改 noname 的全局牌定义。
|
||||
import { _status } from "../../../../noname.js";
|
||||
export const PLAYER_ENERGY = 3;
|
||||
export const PLAYER_HAND_LIMIT = 3;
|
||||
export const SHA_DAMAGE = 4;
|
||||
|
||||
const COSTS = Object.freeze({ sha: 1, tao: 1, jiu: 1, shan: 0, wuxie: 0,
|
||||
juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 });
|
||||
|
||||
export const cardCost = card => COSTS[card?.name] ?? 1;
|
||||
|
||||
export const isActiveCardUse = (event, player) => {
|
||||
if (!event || !player) return false;
|
||||
const seen = new Set();
|
||||
let inPhaseUse = false;
|
||||
for (let current = event; current && !seen.has(current); current = current.parent) {
|
||||
seen.add(current);
|
||||
if (current.name === "dying" || current.name === "chooseToRespond") return false;
|
||||
if (current.name === "chooseToUse" && (current.respondTo || String(current.type || "").startsWith("respond"))) return false;
|
||||
if (current.name === "phaseUse" && current.player === player) inPhaseUse = true;
|
||||
}
|
||||
// 某些扩展创建的主动 useCard 事件不保留 phaseUse 父链。
|
||||
return inPhaseUse || (event.name === "useCard" && event.player === player &&
|
||||
_status.currentPhase === player);
|
||||
};
|
||||
|
||||
export const canPayCard = (player, card) => (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card);
|
||||
|
||||
export const payCard = (player, card) => {
|
||||
if (!canPayCard(player, card)) return false;
|
||||
player.storage.mengsanEnergy_shuying -= cardCost(card);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
attackHitCount,
|
||||
outgoingAttackDamage,
|
||||
} from "./intent-damage.js";
|
||||
|
||||
const pendingDeathBlows = new WeakSet();
|
||||
|
||||
export function isDeathBlowIntent(intent) {
|
||||
return intent?.intentType === "death_blow";
|
||||
}
|
||||
|
||||
export function hasPendingDeathBlow(source) {
|
||||
return pendingDeathBlows.has(source);
|
||||
}
|
||||
|
||||
export async function beginDeathBlow(source, target, intent) {
|
||||
if (!isDeathBlowIntent(intent)) {
|
||||
throw new TypeError("濒死一击需要 death_blow 意图类型");
|
||||
}
|
||||
if (attackHitCount(intent) !== 1) {
|
||||
throw new RangeError("濒死一击只能造成一次伤害");
|
||||
}
|
||||
const damage = outgoingAttackDamage(intent, source);
|
||||
if (!source?.isAlive() || !target?.isAlive()) return false;
|
||||
|
||||
const hit = target.damage(damage, source);
|
||||
hit.mengsanAttack_shuying = true;
|
||||
await hit;
|
||||
if (source.isAlive()) pendingDeathBlows.add(source);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function finishDeathBlow(source) {
|
||||
if (!hasPendingDeathBlow(source)) return false;
|
||||
pendingDeathBlows.delete(source);
|
||||
if (source.isAlive()) await source.die();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Candidate only: attach to fresh, session-owned roots BEFORE scheduling them.
|
||||
const attached = new WeakSet();
|
||||
export function observeFreshEvent(session, event) {
|
||||
if (!event || typeof event.start !== "function" || !event.manager?.eventStack ||
|
||||
event.finished || event.manager.eventStack.includes(event) ||
|
||||
event.parent?.childEvents?.includes(event) || attached.has(event)) {
|
||||
throw new Error("Fresh, unobserved engine event required");
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(event, "start");
|
||||
if (descriptor && !descriptor.configurable) throw new Error("Event start is not configurable");
|
||||
const original = event.start;
|
||||
let resolve, reject;
|
||||
const completion = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
session.ownEvent(event, completion);
|
||||
let observed = false;
|
||||
function start(...args) {
|
||||
let result;
|
||||
try { result = Reflect.apply(original, this, args); }
|
||||
catch (error) { reject(error); throw error; }
|
||||
if (!observed) {
|
||||
observed = true;
|
||||
if (!(result instanceof Promise)) {
|
||||
reject(new TypeError("Native engine start promise required"));
|
||||
} else {
|
||||
result.then(() => {
|
||||
if (event.manager.eventStack.includes(event) || event.manager.tempEvent === event) {
|
||||
reject(new Error("Event still active after start completion"));
|
||||
} else resolve();
|
||||
}, reject);
|
||||
}
|
||||
}
|
||||
return result; // Never assimilate event.then(), never proactively start a queue.
|
||||
}
|
||||
Object.defineProperty(event, "start", { configurable: true, writable: true, value: start });
|
||||
attached.add(event);
|
||||
session.ownResource({}, () => {
|
||||
if (event.start === start) {
|
||||
if (descriptor) Object.defineProperty(event, "start", descriptor);
|
||||
else delete event.start;
|
||||
}
|
||||
attached.delete(event);
|
||||
});
|
||||
return completion;
|
||||
}
|
||||
|
||||
// Independent delay entry: releasing it never calls global resume/pause2/over.resolve.
|
||||
export function acquirePauseLease(session, pauseManager) {
|
||||
if (!session.active || typeof pauseManager?.setDelay !== "function") throw new Error("Active session and pause manager required");
|
||||
let unlock;
|
||||
const gate = new Promise(resolve => { unlock = resolve; });
|
||||
const settled = pauseManager.setDelay(gate);
|
||||
let released = false;
|
||||
const lease = Object.freeze({
|
||||
release() { if (!released) { released = true; unlock(); } return settled; },
|
||||
get released() { return released; },
|
||||
});
|
||||
session.ownResource(lease, () => lease.release());
|
||||
return lease;
|
||||
}
|
||||
|
||||
// Call outside tracked event/task bodies. Own pauses must be lifted before drain.
|
||||
export async function stopAndDrain(session, leases = []) {
|
||||
session.requestStop();
|
||||
await Promise.all(leases.map(lease => lease.release()));
|
||||
await session.drain();
|
||||
}
|
||||
|
||||
// Only remove this exact node from the parent recorded at acquisition.
|
||||
export function ownNode(session, node) {
|
||||
const parent = node.parentNode;
|
||||
if (!parent) throw new Error("Attached node required");
|
||||
return session.ownResource(node, () => {
|
||||
if (node.parentNode === parent) parent.removeChild(node);
|
||||
});
|
||||
}
|
||||
export function ownListener(session, target, type, listener, options) {
|
||||
const guarded = session.guard(listener);
|
||||
const capture = typeof options === "boolean" ? options : !!options?.capture;
|
||||
const token = {};
|
||||
session.ownResource(token, () => target.removeEventListener(type, guarded, capture));
|
||||
target.addEventListener(type, guarded, options);
|
||||
return guarded;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function attackHitCount(intent) {
|
||||
const hits = intent.hits ?? 1;
|
||||
if (!Number.isInteger(hits) || hits < 1) {
|
||||
throw new RangeError("攻击段数必须是正整数");
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function outgoingAttackDamage(intent, source) {
|
||||
if (!Number.isInteger(intent.damage) || intent.damage < 0) {
|
||||
throw new RangeError("预测伤害必须是非负整数");
|
||||
}
|
||||
const storedStrength = source?.storage?.mengsanStrength_shuying;
|
||||
const strength = Number.isInteger(storedStrength) ? storedStrength : 0;
|
||||
let damage = Math.max(0, intent.damage + strength);
|
||||
if (source?.storage?.mengsanWeak_shuying > 0) {
|
||||
damage = Math.floor(damage * 0.75);
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
|
||||
export function previewAttackDamage(intent, source, target) {
|
||||
let damage = outgoingAttackDamage(intent, source);
|
||||
if (target?.storage?.mengsanVulnerable_shuying > 0) {
|
||||
damage = Math.ceil(damage * 1.5);
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Candidate-only engine-derived release. Never replaces Player.prototype or lib.skill.
|
||||
// Restricted: additional/fixed/superCharlotte skills require a separate audited adapter.
|
||||
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 && Object.values(player.additionalSkills || {}).some(list => list?.length)) throw new Error("Additional skill release adapter required");
|
||||
const remove = async function removeSkill(skill) {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
_status.event.clearStepCache();
|
||||
if (Array.isArray(skill)) {
|
||||
for (var i = 0; i < skill.length; i++) {
|
||||
await removeSkill.call(this, skill[i]);
|
||||
}
|
||||
} else {
|
||||
if (skill === "counttrigger") {
|
||||
this.stat[this.stat.length - 1].triggerSkill = {};
|
||||
return;
|
||||
} else {
|
||||
var info = lib.skill[skill];
|
||||
if (info?.fixed && arguments[1] !== true) {
|
||||
return skill;
|
||||
}
|
||||
this.unmarkSkill(skill);
|
||||
game.broadcastAll(
|
||||
function(player, skill2) {
|
||||
player.skills.remove(skill2);
|
||||
player.hiddenSkills.remove(skill2);
|
||||
player.invisibleSkills.remove(skill2);
|
||||
delete player.tempSkills[skill2];
|
||||
for (var i2 in player.additionalSkills) {
|
||||
player.additionalSkills[i2].remove(skill2);
|
||||
}
|
||||
},
|
||||
this,
|
||||
skill
|
||||
);
|
||||
this.checkConflict(skill);
|
||||
if (info) {
|
||||
if (info.onremove) {
|
||||
if (typeof info.onremove == "function") {
|
||||
await info.onremove(this, skill);
|
||||
} else if (typeof info.onremove == "string") {
|
||||
if (info.onremove == "storage") {
|
||||
delete this.storage[skill];
|
||||
} else {
|
||||
var cards = this.storage[skill];
|
||||
if (get.itemtype(cards) == "card") {
|
||||
cards = [cards];
|
||||
}
|
||||
if (get.itemtype(cards) == "cards") {
|
||||
if (this.onremove == "discard") {
|
||||
this.$throw(cards);
|
||||
}
|
||||
if (this.onremove == "discard" || this.onremove == "lose") {
|
||||
game.cardsDiscard(cards);
|
||||
delete this.storage[skill];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(info.onremove)) {
|
||||
for (var i = 0; i < info.onremove.length; i++) {
|
||||
delete this.storage[info.onremove[i]];
|
||||
}
|
||||
} else if (info.onremove === true) {
|
||||
delete this.storage[skill];
|
||||
}
|
||||
}
|
||||
this.removeSkillTrigger(skill);
|
||||
if (!wholePlayer && !info.keepSkill) {
|
||||
this.removeAdditionalSkills(skill);
|
||||
}
|
||||
}
|
||||
this.enableSkill(skill + "_awake");
|
||||
game.callHook("removeSkillCheck", [skill, this]);
|
||||
}
|
||||
}
|
||||
return skill;
|
||||
};
|
||||
return remove.call(player, skill);
|
||||
}
|
||||
|
||||
// Must be called after gameplay root drained, outside all engine event bodies.
|
||||
export async function runSkillTeardown(releases, {lib,_status}) {
|
||||
if (_status.eventManager.eventStack.length || _status.eventManager.tempEvent) throw new Error("Gameplay must drain before skill release");
|
||||
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;
|
||||
root.setContent(async () => {
|
||||
try {
|
||||
for (const release of releases) {
|
||||
await release();
|
||||
await root.waitNext();
|
||||
}
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
// Let the engine drain any queued work and pop this root normally.
|
||||
}
|
||||
});
|
||||
// Natural start completion includes next/after and native stack pop. Failure stays fail-closed.
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
let operation;
|
||||
const names = player => [...new Set([
|
||||
...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []),
|
||||
...Object.keys(player.tempSkills || {}),
|
||||
...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []),
|
||||
])];
|
||||
return () => {
|
||||
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);
|
||||
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) {
|
||||
if (released.size >= 128) throw new Error("Skill teardown exceeded bounded expansion");
|
||||
const name = names(player)[0];
|
||||
if (released.has(name)) throw new Error("Released skill was re-added: " + name);
|
||||
await removeBattleSkill(player, name, env, true);
|
||||
released.add(name);
|
||||
// Children can add skills. Drain them before taking the next inventory.
|
||||
await env._status.event.waitNext();
|
||||
}
|
||||
for (const [key,list] of Object.entries(player.additionalSkills || {})) {
|
||||
if (Array.isArray(list) && !list.length) delete player.additionalSkills[key];
|
||||
}
|
||||
}), env);
|
||||
})();
|
||||
return operation;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const stunned = new WeakMap();
|
||||
|
||||
export const STUN_INTENT = Object.freeze({ intentType: "stun" });
|
||||
|
||||
export function isStunIntent(intent) {
|
||||
return intent?.intentType === "stun";
|
||||
}
|
||||
|
||||
export function isStunned(player) {
|
||||
return stunned.has(player);
|
||||
}
|
||||
|
||||
export function applyStun(player, intent, options = {}) {
|
||||
if (stunned.has(player)) return false;
|
||||
const { resume = "advance", recover } = options;
|
||||
if (resume !== "advance" && resume !== "retry") {
|
||||
throw new RangeError("击晕恢复方式无效");
|
||||
}
|
||||
if (typeof recover !== "function") {
|
||||
throw new TypeError("击晕需要恢复回调");
|
||||
}
|
||||
if (!player?.isAlive()) return false;
|
||||
stunned.set(player, { intent, resume, recover, skipped: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function skipStunnedAction(player, phaseUse) {
|
||||
const state = stunned.get(player);
|
||||
if (!state || state.skipped) return false;
|
||||
phaseUse.cancel();
|
||||
state.skipped = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function finishStunnedTurn(player) {
|
||||
const state = stunned.get(player);
|
||||
if (!state?.skipped) return false;
|
||||
stunned.delete(player);
|
||||
if (player.isAlive()) await state.recover(state.resume, state.intent);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearStun(player) {
|
||||
return stunned.delete(player);
|
||||
}
|
||||
Reference in New Issue
Block a user