fixed # 重构梦三项目结构

This commit is contained in:
2026-09-30 10:23:58 +08:00
parent 3fdaf6d218
commit f2da9bce65
53 changed files with 267 additions and 175 deletions
+180
View File
@@ -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(); },
};
}
+103
View File
@@ -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");
}
+87
View File
@@ -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;
}
}
+55
View File
@@ -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];
}
});
},
});
}
+106
View File
@@ -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());
}
+104
View File
@@ -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};
});
},
});
}
+33
View File
@@ -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;
};
+38
View File
@@ -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;
}
+83
View File
@@ -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;
}
+28
View File
@@ -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;
}
+147
View File
@@ -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;
};
}
+45
View File
@@ -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);
}