Merge origin/main: preserve relic system and diagnostics updates

This commit is contained in:
2026-10-08 23:53:32 +08:00
27 changed files with 5611 additions and 1148 deletions
+51 -20
View File
@@ -1,67 +1,89 @@
// Candidate external coordinator. Finish callbacks return a boolean, NEVER a drain promise.
import { getDiagnostics } from "../../diagnostics/index.js";
const copy = value => JSON.parse(JSON.stringify(value));
export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) {
export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, releaseBattle = () => {}, presentVictory = async () => {}}) {
let state = "map", active = null, work = null, job = null, error = null;
let phase = "map", attempt = 0;
const diagnostics = getDiagnostics().scope("mengsan.flow", () => ({
state, phase, attempt, sessionId: active?.session.id,
sessionState: active?.session.state,
pendingTasks: active?.session.pendingCount,
runId: active?.run.runId, revision: active?.run.revision,
nodeId: active?.node.id, settlementId: job?.pending?.id,
outcome: job?.input?.outcome, commitReceipt: !!job?.committed,
skillsReleased: !!job?.skillsReleased, delivered: !!job?.delivered,
}));
const step = (name, action, wait = true) => {
phase = name;
return diagnostics.step(name, action, {}, { observeWait: wait });
};
async function drive() {
try {
state = "draining";
await active.session.drain();
await step("drain", () => 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);
await step("quiesce.beforePrepare", quiesce);
if (!job.pending) job.pending = await step("prepare", () => settlement.prepare(job.input));
if (job.pending.outcome === "victory" && !job.victoryPresented) {
await presentVictory(copy(job.pending));
await step("victoryDialogue", () => presentVictory(copy(job.pending)), false);
job.victoryPresented = true;
}
if (job.pending.state !== "chosen") {
state = "choosing";
if (!job.hasChoice) {
job.choiceId = job.pending.outcome === "defeat" ? await chooseDefeat(copy(job.pending.base))
: await chooseReward(copy(job.pending.choices), {
job.choiceId = await step("rewardSelection", () => job.pending.outcome === "defeat" ? chooseDefeat(copy(job.pending.base))
: chooseReward(copy(job.pending.choices), {
boss: job.pending.boss,
elite: job.pending.elite,
fixedRewards: copy(job.pending.fixedRewards || []),
rewardPackage: copy(job.pending.rewardPackage || null),
});
}), false);
if (job.pending.outcome !== "defeat" && !job.pending.rewardPackage &&
job.choiceId !== null &&
!job.pending.choices.some(c => c.id === job.choiceId && c.kind !== "gold")) throw new Error("Invalid reward selection");
job.hasChoice = true;
}
job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId);
job.pending = await step("applyChoice", () => 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);
job.committed = await step("commit", () => settlement.commit(job.input.run.runId, job.pending.id));
}
if (!job.skillsReleased) {
state = "releasingSkills";
await releaseSkills();
await step("releaseSkills", releaseSkills);
job.skillsReleased = true;
await quiesce();
await step("quiesce.afterSkills", quiesce);
}
state = "cleaning";
await active.session.dispose(); // Failed cleanup retry does NOT replay a committed reward.
await quiesce();
await step("dispose", () => active.session.dispose()); // Failed cleanup retry does NOT replay a committed reward.
await step("quiesce.afterDispose", quiesce);
if (!job.battleReleased) {
await step("releaseBattle", releaseBattle);
job.battleReleased = true;
}
if (!job.delivered) {
state = "presenting";
if (job.committed.route === "map") {
const mapRun = copy(job.committed.run);
job.nextNode = await showMap(mapRun);
job.nextNode = await step("showMap", () => showMap(mapRun), false);
job.committed.run = mapRun;
}
else if (job.committed.route !== "new") {
await showEnding(job.committed.route, copy(job.committed.run));
await step("showEnding", () => showEnding(job.committed.route, copy(job.committed.run)), false);
}
job.delivered = true;
}
state = job.committed.route === "map" ? "map" : "ended";
diagnostics.info("settlement.complete", { route: job.committed.route });
return {...copy(job.committed), nextNode:copy(job.nextNode ?? null)};
} catch (cause) {
diagnostics.error("settlement.failed", cause, { failedPhase: phase });
error = cause; state = "retryable"; throw cause;
}
}
function launch() {
attempt++;
error = null; state = "draining";
work = Promise.resolve().then(drive);
// The engine callback is fire-and-forget; keep failure observable without unhandled rejection.
@@ -76,24 +98,33 @@ export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMa
if (!saved || saved.runId !== run.runId || saved.revision !== run.revision) throw new Error("Stale battle input");
state = "starting";
try {
await quiesce();
await step("quiesce.beforeBattle", 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; }
state = "battle"; attempt = 0; start();
diagnostics.info("battle.started");
} catch (cause) {
diagnostics.error("battle.start.failed", cause, { runId: run.runId, nodeId: node.id });
state = "blocked"; error = cause; throw cause;
}
},
requestFinish({session, hp, outcome = "victory", defeatedEnemies = 1}) {
if (state !== "battle" || session !== active.session) return false;
if (state !== "battle" || session !== active.session) {
diagnostics.info("finish.ignored", { requestedSessionId: session?.id, outcome });
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";
diagnostics.info("finish.accepted", { hp, defeatedEnemies });
active.session.requestStop();
launch();
return true; // No game.pause/resume: stop loop, let current body unwind, then open rewards.
},
retry() {
if (state !== "retryable") throw new Error("No failed settlement to retry");
diagnostics.info("settlement.retry");
launch(); return work;
},
wait() { if (!work) throw new Error("No settlement requested"); return work; },
+4 -1
View File
@@ -20,11 +20,13 @@ export function createBattleResources(session, {game, ui, _status}) {
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()) {
for (const {object,key,value,descriptor} of fields.slice().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;
cards.clear(); nodes.clear(); fields.length = 0;
before.clear(); uiBefore.clear();
});
return Object.freeze({
field,
@@ -49,6 +51,7 @@ export function createBattleResources(session, {game, ui, _status}) {
for (const key of ["currentPhase","lastPhasedPlayer","roundStart"]) {
if (owned.has(_status[key])) delete _status[key];
}
owned.clear();
});
},
});
+16 -2
View File
@@ -1,4 +1,5 @@
// Candidate only. No engine globals, event prototype patches, or save writes.
import { getDiagnostics } from "../../diagnostics/index.js";
const owners = new WeakMap();
export function createBattleSession(id) {
@@ -7,6 +8,11 @@ export function createBattleSession(id) {
let state = "running", disposePromise = null;
const pending = new Set(), events = new Set(), resources = new Map(), errors = [];
const waiters = new Set();
const diagnostics = getDiagnostics().scope("mengsan.session", () => ({
sessionId: id, state, pendingTasks: pending.size,
eventCount: events.size, resourceCount: resources.size,
recordedErrors: errors.length,
}));
const notify = () => { for (const wake of waiters) wake(); waiters.clear(); };
const ensureRunning = () => { if (state !== "running") throw new Error("Session is stopping"); };
const claim = object => {
@@ -20,6 +26,7 @@ export function createBattleSession(id) {
if (!(promise instanceof Promise)) throw new TypeError("Native completion Promise required");
pending.add(promise);
promise.then(() => { pending.delete(promise); notify(); }, error => {
diagnostics.error("task.failed", error);
errors.push(error); pending.delete(promise); notify();
});
return promise;
@@ -58,9 +65,13 @@ export function createBattleSession(id) {
requestStop() {
if (state !== "running") return false;
state = "stopping";
diagnostics.info("stop.requested");
// 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); }
try { event.finish(); } catch (error) {
diagnostics.error("event.finish.failed", error, { eventName: event.name });
errors.push(error);
}
}
notify();
return true;
@@ -80,7 +91,10 @@ export function createBattleSession(id) {
disposePromise = (async () => {
// Reverse acquisition order; stop at first failure to preserve dependent resources.
for (const [resource, release] of [...resources].reverse()) {
await release(resource);
await diagnostics.step("resource.release", () => release(resource), {
resourceType: resource?.constructor?.name,
playerId: resource?.playerid, nodeName: resource?.nodeName,
});
resources.delete(resource); owners.delete(resource);
}
for (const event of events) owners.delete(event);
+23 -3
View File
@@ -6,6 +6,7 @@ import { recordNormalBattleVictory } from "../progression/encounter-progress.js"
import { stripBattleProgress } from "../progression/map-checkpoint.js";
import { grantGold } from "../relics/progression.js";
import { heldRelics } from "../relics/definitions.js";
import { getDiagnostics } from "../../diagnostics/index.js";
import { prepareRewardPackage, applyRewardPackage }
from "../progression/reward-package.js";
@@ -16,6 +17,11 @@ export function createBattleSettlement({
enterNextAct, createRun, now = Date.now, logRewardPackage = () => {}, resolveRelicChoices = async () => {},
}) {
let item = null, committed = null, commitPromise = null, choicePromise = null, choiceSignature = null;
const diagnostics = getDiagnostics().scope("mengsan.settlement", () => ({
settlementId: item?.id, runId: item?.base.runId,
nodeId: item?.nodeId, pendingState: item?.state,
route: item?.route, commitReceipt: !!committed,
}));
const readRun = () => store.read()[config.saveKey];
function current(storage, runId) {
const run = storage[config.saveKey];
@@ -29,6 +35,11 @@ export function createBattleSettlement({
function validateCheckpoint(run, snapshot, nodeId) {
if (run.actIndex !== snapshot.actIndex || run.revision !== snapshot.revision ||
run.map?.nodes.find(node => node.id === nodeId)?.completed !== false) {
diagnostics.warn("checkpoint.mismatch", {
expectedRevision: snapshot.revision, actualRevision: run.revision,
expectedAct: snapshot.actIndex, actualAct: run.actIndex,
nodeId, completed: run.map?.nodes.find(node => node.id === nodeId)?.completed,
});
throw new Error("Stale node or revision");
}
}
@@ -78,6 +89,10 @@ export function createBattleSettlement({
getRandomRewardChoices) : null;
item = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(fixedRewards), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, elite, outcome, relicRecovery, bondGrowth};
item.rewardPackage = copy(rewardPackage);
diagnostics.info("prepared", {
outcome, choices: choices.map(choice => choice.id), fixedRewards,
expectedRevision: snapshot.revision, actualRevision: run.revision,
});
if (outcome === "victory" && !rewardPackage) {
item.rewardMode = "one-or-skip";
}
@@ -152,6 +167,7 @@ export function createBattleSettlement({
// 在内存锁定最终结果(包括随机牌实例),只在 commit 写入地图进度。
item.state = "chosen"; item.choiceId = copy(choiceId); item.result = stripBattleProgress(result);
item.route = route; item.completedAt = now();
diagnostics.info("choice.applied", { choiceId, route });
if (item.rewardPackage && item.outcome === "victory") {
logRewardPackage(item.rewardPackage, choiceId);
}
@@ -160,8 +176,11 @@ export function createBattleSettlement({
async commit(runId, id) {
const item = pending(runId, id);
if (item.state !== "chosen") throw new Error("Player choice required before saving");
if (committed) return {...copy(committed), duplicate:true};
if (commitPromise) return commitPromise;
if (committed) {
diagnostics.info("commit.duplicate");
return {...copy(committed), duplicate:true};
}
if (commitPromise) { diagnostics.info("commit.inflight"); return commitPromise; }
commitPromise = store.update(storage => {
const saved = storage[config.saveKey];
const prior = storage[config.profileKey]?.completedRuns?.find(r => r.runId === runId && r.settlementId === id);
@@ -191,8 +210,9 @@ export function createBattleSettlement({
else delete storage[config.saveKey]; // Same transaction as profile; never a second clear write.
}
return {route:item.route, run:item.route === "new" ? copy(item.nextRun) : result, duplicate:false};
}).then(result => {
}, { runId, settlementId: id, nodeId: item.nodeId }).then(result => {
committed = copy(result);
diagnostics.info("commit.receipt", { duplicate: result.duplicate, route: result.route });
return copy(committed);
}).finally(() => { commitPromise = null; });
return commitPromise;
+50 -9
View File
@@ -64,7 +64,7 @@ import { STRANGLER_CHARACTER, isStrangler, rollStranglerHp, recordStranglerActio
import { JAXFRUIT_CHARACTER, isJaxfruit, rollJaxfruitHp, recordJaxfruitAction } from "./monsters/jaxfruit-intent.js";
import { CRAWLER_CHARACTER, isCrawler, rollCrawlerHp, recordCrawlerAction } from "./monsters/crawler-intent.js";
import { INKLET_CHARACTER, isInklet, rollInkletHp, recordInkletAction, capInkletHpLoss } from "./monsters/inklet-intent.js";
import { mountEnemyIntent } from "./ui/intent-display.js";
import { mountEnemyIntent, refreshEnemyIntents } from "./ui/intent-display.js";
import { createFogmogBattle } from "./monsters/fogmog-battle.js";
import { createPhrogBattle } from "./monsters/phrog-battle.js";
import { PHROG_CHARACTER, WRIGGLER_CHARACTER, INFESTED_COUNT, isPhrogActor, isWriggler, rollPhrogHp, rollWrigglerHp, recordPhrogAction, initializePhrog, skipSpawnedWriggler } from "./monsters/phrog-intent.js";
@@ -96,6 +96,7 @@ import { mountGMManager } from "./ui/gm-manager.js";
import { createPlayerTeardown } from "./battle/skill-teardown.js";
import { createBattleResources } from "./battle/battle-resources.js";
import { createModeStorage } from "./progression/mode-storage.js";
import { getDiagnostics } from "../diagnostics/index.js";
import { createBattleSession } from "./battle/battle-session.js";
import { observeFreshEvent } from "./battle/engine-session.js";
import { runPhaseLoop, stopBattleTurn } from "./battle/battle-loop.js";
@@ -143,7 +144,26 @@ const clearRun = modeStorage.clearRun;
const showMap = run => renderMap(run, { saveRun });
let battleSequence = 0;
let activeBattle = null;
const reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); };
const diagnosticService = getDiagnostics();
const diagnostics = diagnosticService.scope("mengsan.mode");
diagnosticService.registerContext("mengsan", () => {
const run = _status.mengsanRun_shuying;
const manager = _status.eventManager;
return {
runId: run?.runId, revision: run?.revision, actIndex: run?.actIndex,
character: run?.player?.character, hp: run?.player?.hp,
sessionId: activeBattle?.session.id,
flowState: activeBattle?.flow.state, pendingTasks: activeBattle?.session.pendingCount,
save: modeStorage.lastWrite,
engine: { paused: _status.paused, paused2: _status.paused2,
tempEvent: manager?.tempEvent?.name,
eventStack: manager?.eventStack?.slice(-8).map(event => ({ name: event?.name })) },
};
});
const reportFlowError = error => {
diagnostics.error("journey.failed", error);
console.error("梦三流程暂停,未自动清档:", error);
};
const chooseRun = async savedRun => {
const available = savedRun?.status === "running";
@@ -663,7 +683,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
});
for (const skill of [
"mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_ic_bridge_shuying", "mengsan_shared_bridge_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_intent_refresh_shuying",
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
"mengsan_fogmog_action_shuying", "mengsan_mawler_action_shuying",
@@ -756,8 +776,9 @@ const registerPlayers = (session, players) => {
});
}
};
const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
const makeFlow = (releaseSkills = async () => {}, releaseBattle = () => {}) => createBattleFlow({
releaseSkills,
releaseBattle,
presentVictory: pending => {
if (pending.bondGrowth) {
const growth = pending.bondGrowth;
@@ -803,7 +824,10 @@ const requestBattleFinish = outcome => {
const current = activeBattle;
if (!current) return false;
// Complete the original card and all queued replays before snapshotting a victory.
if(outcome==="victory" && current.sharedPendingUses?.size){current.sharedDeferredVictory=true;return false;}
if(outcome==="victory" && current.sharedPendingUses?.size){
diagnostics.info("victory.deferred", { pendingUses: current.sharedPendingUses.size });
current.sharedDeferredVictory=true;return false;
}
for (const player of game.dead) {
recordBondDeath(player, _status.mengsanRun_shuying,
message => game.log(message));
@@ -826,10 +850,12 @@ const awaitSettlement = async (flow, initial = flow.wait()) => {
try { return await operation; }
catch (error) {
if (flow.state !== "retryable") throw error;
diagnostics.error("settlement.prompt", error, { flowState: flow.state });
const action = await chooseButtons("结算未完成", [
{id:"retry",name:"重试保存与结算"},
{id:"exit",name:"保留已保存进度并返回模式选择"},
], "已提交的奖励不会重发。技能释放失败时请返回模式选择,避免重复调用释放回调。");
diagnostics.info("settlement.prompt.action", { action });
if (action === "exit") { await openModeSelection(); throw error; }
operation = flow.retry();
}
@@ -840,14 +866,20 @@ const playBattle = async (run, node, encounter) => {
if (lib.onfree) await new Promise(resolve => lib.onfree.push(resolve));
await quiesceEngine(_status);
const session = createBattleSession(`${run.runId}:${++battleSequence}`);
const resources = createBattleResources(session, {game,ui,_status});
let current;
const flow = makeFlow(() => current.releaseSkills?.());
let resources = createBattleResources(session, {game,ui,_status});
let current, root;
const flow = makeFlow(() => current.releaseSkills?.(), () => {
if (activeBattle === current) activeBattle = null;
for (const key of Object.keys(current)) {
if (key !== "session" && key !== "flow") delete current[key];
}
root = null; resources = null;
});
let finished;
const signal = new Promise(resolve => { finished = resolve; });
current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map(), intentUI: new Map()};
activeBattle = current;
const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager);
root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager);
current.root = root;
const previousRoot = _status.eventManager.rootEvent;
session.ownResource({}, () => {
@@ -1756,6 +1788,15 @@ const createMode = () => {
else player.removeSkill("mengsan_vulnerable_shuying");
},
},
mengsan_intent_refresh_shuying: {
trigger: { global: ["useCardAfter", "useSkillAfter", "respondAfter", "changeHpAfter", "dieAfter", "phaseAfter"] },
forced: true, silent: true, popup: false, priority: -1000,
filter() { return Boolean(activeBattle?.session.active); },
async content() {
const session = activeBattle?.session;
if (session?.active) refreshEnemyIntents(session);
},
},
mengsan_scenario_shuying: {
trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] },
forced: true, silent: true, popup: false, priority: -100,
+47 -9
View File
@@ -1,8 +1,11 @@
// Candidate factory. Storage writer body reused from batch2 MODIFIED_STORAGE.js.
import { migrateRetiredCards } from "../cards/retired-cards.js";
import { stripBattleProgress } from "./map-checkpoint.js";
import { getDiagnostics } from "../../diagnostics/index.js";
export function createModeStorage({lib, game, config, localStorage, alert = () => {}, console = globalThis.console}) {
const MODE_ID = config.modeId;
const diagnostics = getDiagnostics().scope("mengsan.storage");
let writeSequence = 0, lastWrite = null;
const copy = value => JSON.parse(JSON.stringify(value));
@@ -16,10 +19,14 @@ const copyStorage = () => {
// Keep one mode-level writer; a failed write must not poison later retries.
let modeStorageQueue = Promise.resolve();
const writeModeStorage = storage => {
const writeModeStorage = (storage, receipt) => {
receipt.phase = "storage.write";
receipt.storageStatus = "pending";
if (!lib.db) {
// Do not switch backends after an IndexedDB error: that creates two saves.
localStorage.setItem(`${lib.configprefix}${MODE_ID}`, JSON.stringify(storage));
receipt.storageStatus = "committed";
diagnostics.info("storage.committed", receipt);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -28,11 +35,18 @@ const writeModeStorage = storage => {
try {
// game.putDB resolves at request success, which is before commit.
transaction = lib.db.transaction(["data"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.oncomplete = () => {
receipt.storageStatus = "committed";
diagnostics.info("transaction.committed", receipt);
resolve();
};
transaction.onerror = event => {
requestError = event.target?.error;
};
transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三存档事务已中止"));
transaction.onabort = () => {
receipt.storageStatus = "aborted";
reject(transaction.error || requestError || new Error("梦三存档事务已中止"));
};
transaction.objectStore("data").put(storage, MODE_ID);
}
catch (error) {
@@ -45,18 +59,39 @@ const writeModeStorage = storage => {
});
};
const persistModeStorage = (update = () => {}) => {
const persistModeStorage = (update = () => {}, context = {}) => {
const receipt = { ...context, writeId: ++writeSequence, phase: "queued",
backend: lib.db ? "indexeddb" : "localStorage",
storageStatus: "notStarted", memoryPublished: false };
diagnostics.info("save.queued", receipt);
// Hold the engine reload guard from enqueue through commit and publication.
// reload2 also drains the engine's queued database operations on release.
lib.status.reload++;
const operation = modeStorageQueue.then(async () => {
lastWrite = receipt;
receipt.phase = "snapshot";
const storage = copyStorage();
receipt.phase = "mutator";
update(storage);
stripBattleProgress(storage[config.saveKey]);
storage.version = lib.version;
await writeModeStorage(storage);
await writeModeStorage(storage, receipt);
receipt.phase = "memory.publish";
lib.storage = storage;
}).finally(() => game.reload2());
receipt.memoryPublished = true;
diagnostics.info("memory.published", receipt);
}).catch(error => {
diagnostics.error("save.failed", error, receipt);
throw error;
}).finally(() => {
receipt.phase = "reloadGuard.release";
try { game.reload2(); }
catch (error) {
diagnostics.error("reloadGuard.failed", error, receipt);
throw error;
}
diagnostics.info("save.finished", receipt);
});
modeStorageQueue = operation.catch(() => {});
return operation;
};
@@ -67,9 +102,11 @@ const saveRun = async run => {
const snapshot = migrateRetiredCards(stripBattleProgress(copy(run)));
await persistModeStorage(storage => {
storage[config.saveKey] = snapshot;
});
}, { runId: snapshot.runId, revision: snapshot.revision });
}
catch (error) {
diagnostics.error("saveRun.failed", error,
{ runId: run?.runId, revision: run?.revision, lastWrite });
// Neither delete the previous save nor attempt a second destructive write.
alert("梦三存档失败,未覆盖上一次成功保存的进度。当前进度尚未保存,请勿刷新,并查看控制台错误。");
console.error("梦三存档失败:", error);
@@ -87,13 +124,14 @@ const clearRun = async () => {
return Object.freeze({
read: copyStorage,
get lastWrite() { return lastWrite ? { ...lastWrite } : null; },
saveRun, clearRun,
async update(mutator) {
async update(mutator, context = {}) {
let value;
await persistModeStorage(storage => {
value = mutator(storage);
if (value && typeof value.then === "function") throw new TypeError("Storage mutator must be synchronous");
});
}, context);
return value === undefined ? undefined : copy(value);
},
});
+3
View File
@@ -2,6 +2,7 @@ import { bondDefinitions, getBondCombat, getBondDeck }
from "../bonds/definitions.js";
import { getBondIntents } from "../bonds/intents.js";
import { bondProbability, ensureBonds, selectBond } from "../bonds/state.js";
import { getDiagnostics } from "../../diagnostics/index.js";
export function renderBonds(parent, run, saveRun, onBusy = () => {}) {
const bonds = ensureBonds(run);
@@ -33,6 +34,8 @@ export function renderBonds(parent, run, saveRun, onBusy = () => {}) {
updated.querySelector('button[aria-pressed="true"]')
?.focus({ preventScroll: true });
} catch (error) {
getDiagnostics().scope("mengsan.bonds").error("selection.save.failed", error,
{ runId: run.runId, revision: run.revision, bondId: id });
selectBond(run, previous);
status.textContent = "保存失败,请重试。";
console.error("梦三助战选择保存失败", error);
+43 -30
View File
@@ -132,15 +132,26 @@ export function openCardLibrary(run, options = {}) {
if (typeof window !== "undefined") window.removeEventListener?.("resize", fitCards);
if (dialog.open) dialog.close();
dialog.remove();
list.replaceChildren(); detailPanel.replaceChildren(); body.replaceChildren();
dialog.replaceChildren();
catalogRecords = null; descriptionCache = new WeakMap();
selectedButton = null; current = [];
virtualGrid = null; sizeObserver = null;
if (previousFocus?.isConnected) previousFocus.focus();
const onClose = options.onClose;
options = {}; run = null;
onClose?.(close);
};
button("关闭", header, close);
let view = !options.sections && options.view === "catalog" ? "catalog" : "deck";
const navigation = element("nav", "ms-deck-tabs", null, dialog);
navigation.setAttribute("aria-label", "卡牌列表视图");
navigation.hidden = Boolean(options.sections);
const deckTab = button("持有牌", navigation, () => { view = "deck"; render(); });
const catalogTab = button("图鉴", navigation, () => { view = "catalog"; render(); });
deckTab.hidden = catalogTab.hidden = Boolean(options.sections);
let sectionIndex = 0;
const sectionTabs = options.sections ? options.sections().map((section, index) =>
button(section.title, navigation, () => { sectionIndex = index; render(); })) : [];
const filters = element("div", "ms-catalog-filters", null, dialog);
function field(label) {
const wrapper = element("label", "ms-catalog-field", null, filters);
@@ -179,13 +190,13 @@ export function openCardLibrary(run, options = {}) {
summary.setAttribute("aria-live", "polite");
const body = element("div", "ms-deck-body", null, dialog);
const footer = element("p", "ms-deck-footer", "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序", dialog);
const current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
let detail = false, selectedButton = null, scrollTop = 0;
const list = element("div", options.sections ? "ms-deck-grid" : "ms-deck-grid ms-library-grid", null, body);
let current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
let detail = false, selectedButton = null, scrollTop = 0, orderedView = false;
const list = element("div", "ms-deck-grid ms-library-grid", null, body);
const detailPanel = element("section", "ms-deck-detail", null, body);
detailPanel.hidden = true;
let catalogRecords = null, descriptionCache = new WeakMap();
if (!options.sections) virtualGrid = createVirtualCardGrid(body, list, createTile);
virtualGrid = createVirtualCardGrid(body, list, createTile);
function back() {
detail = false; detailPanel.hidden = true; list.hidden = false;
detailPanel.replaceChildren(); body.scrollTop = scrollTop; fitCards();
@@ -214,12 +225,13 @@ export function openCardLibrary(run, options = {}) {
body.scrollTop = 0; backButton.focus();
}
function fitCards() {
if (options.sections || closed || !body.clientHeight) return;
if (closed || !body.clientHeight) return;
const padding = getComputedStyle(body);
const available = body.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
const height = Math.max(64, Math.min(218, Math.floor((available - 8) / 2)));
const rows = available >= 264 ? 2 : 1;
const height = Math.max(128, Math.min(218, Math.floor((available - (rows - 1) * 8) / rows)));
dialog.style.setProperty("--ms-card-height", String(height) + "px");
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - 80) * 5 / 7)))) + "px");
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - (orderedView ? 104 : 80)) * 5 / 7)))) + "px");
virtualGrid?.layout(height);
}
function createTile(record, index) {
@@ -240,7 +252,6 @@ export function openCardLibrary(run, options = {}) {
clearTimeout(searchTimer);
detail = false; list.hidden = false; detailPanel.hidden = true;
detailPanel.replaceChildren();
if (!virtualGrid) list.replaceChildren();
body.scrollTop = 0;
selectedButton = null;
const catalog = !options.sections && view === "catalog";
@@ -248,8 +259,22 @@ export function openCardLibrary(run, options = {}) {
deckTab.setAttribute("aria-pressed", String(!catalog));
catalogTab.setAttribute("aria-pressed", String(catalog));
list.classList.toggle("ms-catalog-grid", catalog);
let records = [];
if (catalog) {
let records = [], emptyMessage = "此处暂无卡牌。";
orderedView = false;
if (options.sections) {
const sections = options.sections();
const section = sections[sectionIndex];
orderedView = Boolean(section?.ordered);
if (section?.ordered) emptyMessage = "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。";
sectionTabs.forEach((tab, index) => {
tab.textContent = `${sections[index].title} · ${sections[index].cards.length}`;
tab.setAttribute("aria-pressed", String(index === sectionIndex));
});
records = (section?.cards || []).map(card => ({ card, ordered: section.ordered }));
summary.textContent = section?.ordered
? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。"
: `${section?.title || "牌堆"} · 共 ${records.length} 张`;
} else if (catalog) {
const query = search.value.trim().toLocaleLowerCase();
catalogRecords ||= getCardCatalog().map(card => {
const details = describeCatalogCard(card);
@@ -273,24 +298,11 @@ export function openCardLibrary(run, options = {}) {
}
footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" :
"点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序";
if (virtualGrid) {
virtualGrid.setRecords(catalog ? records : current.map(card => ({ card })));
if (!(catalog ? records : current).length) {
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : "此处暂无卡牌。", list);
}
fitCards();
return;
}
const sections = catalog ? [{ title: "", cards: records.map(record => record.card) }] :
options.sections ? options.sections() : [{ title: "", cards: current }];
for (const section of sections) {
const cards = section.cards;
if (section.title) element("h3", "ms-deck-section", `${section.title} · ${cards.length} 张`, list);
if (!cards.length) element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
cards.forEach((card, index) => {
list.appendChild(createTile({ card, ordered: section.ordered }, index));
});
virtualGrid.setRecords(catalog || options.sections ? records : current.map(card => ({ card })));
if (!(catalog || options.sections ? records : current).length) {
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : emptyMessage, list);
}
fitCards();
}
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
dialog.addEventListener("close", close);
@@ -300,10 +312,10 @@ export function openCardLibrary(run, options = {}) {
document.body.appendChild(dialog);
try { dialog.showModal(); } catch (error) { close(); throw error; }
fitCards();
if (!options.sections && typeof ResizeObserver !== "undefined") {
if (typeof ResizeObserver !== "undefined") {
sizeObserver = new ResizeObserver(fitCards);
sizeObserver.observe(body);
} else if (!options.sections && typeof window !== "undefined") {
} else if (typeof window !== "undefined") {
window.addEventListener?.("resize", fitCards);
}
header.querySelector("button").focus();
@@ -327,6 +339,7 @@ export function mountBattlePiles(session, battle) {
closeDialog?.();
closeDialog = openCardLibrary(null, {
title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆",
onClose(handle) { if (closeDialog === handle) closeDialog = null; },
sections: () => [
{ title: "剩余牌堆", cards: battle.drawPile, ordered: true },
{ title: "弃牌堆", cards: battle.discardPile, ordered: false },
+9
View File
@@ -1,8 +1,17 @@
import { getDiagnostics } from "../../../diagnostics/index.js";
const lines = [];
let pendingWrite = Promise.resolve();
export function createHandDiagnostics(game, sessionId) {
const service = getDiagnostics();
const diagnostics = service.scope("mengsan.hand", { sessionId });
return (message, error) => {
if (service.status().enabled) {
if (error) diagnostics.error("hand.failed", error, { message });
else diagnostics.info("hand.message", { message });
return;
}
const line = `${new Date().toISOString()} [${sessionId}] ${message}` +
(error ? ` ${error.message || error}` : "");
console[error ? "warn" : "info"](`梦三手牌 UI:${line}`);
+23 -2
View File
@@ -20,6 +20,7 @@ export async function mountHandUI(player, session, env) {
let disposed = false;
let observer, resizeObserver, pointer = null, nativeInput = false;
let finishStyleLoad = null, refreshTimer = null;
let refreshFrame = null;
const touchScrollClasses = new Map();
let layoutEnabled = false, warned = false;
const log = (message, error) => {
@@ -72,6 +73,15 @@ export async function mountHandUI(player, session, env) {
}
overlays.sync(adapter.cards());
}
function scheduleRefresh() {
if (disposed || document.hidden || refreshFrame !== null) return;
const draw = () => {
refreshFrame = null;
if (!document.hidden) refresh();
};
refreshFrame = typeof window.requestAnimationFrame === "function"
? window.requestAnimationFrame(draw) : window.setTimeout(draw, 16);
}
function listen(target, type, listener, options) {
target.addEventListener(type, listener, options);
restores.push(() => target.removeEventListener(type, listener, options));
@@ -120,6 +130,11 @@ export async function mountHandUI(player, session, env) {
if (disposed || !nativeInput) return;
nativeInput = false;
window.clearTimeout(refreshTimer);
if (refreshFrame !== null) {
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
else window.clearTimeout(refreshFrame);
refreshFrame = null;
}
refreshTimer = window.setTimeout(refresh, 0);
}
function restoreTouchScroll() {
@@ -154,6 +169,11 @@ export async function mountHandUI(player, session, env) {
disposed = true;
finishStyleLoad?.();
window.clearTimeout(refreshTimer);
if (refreshFrame !== null) {
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
else window.clearTimeout(refreshFrame);
refreshFrame = null;
}
restoreTouchScroll();
observer?.disconnect();
resizeObserver?.disconnect();
@@ -201,7 +221,7 @@ export async function mountHandUI(player, session, env) {
listen(container, "touchend", () => restoreTouchScroll(), true);
listen(container, "touchcancel", () => restoreTouchScroll(), true);
}
observer = new window.MutationObserver(refresh);
observer = new window.MutationObserver(scheduleRefresh);
for (const zone of adapter.zones) {
observer.observe(zone, { childList: true, subtree: true,
attributes: true, attributeFilter: ["class"] });
@@ -211,7 +231,7 @@ export async function mountHandUI(player, session, env) {
observer.observe(adapter.root.parentNode, { childList: true });
}
if (window.ResizeObserver && adapter.root) {
resizeObserver = new window.ResizeObserver(refresh);
resizeObserver = new window.ResizeObserver(scheduleRefresh);
resizeObserver.observe(adapter.root);
}
if (window.PointerEvent && adapter.root) {
@@ -221,6 +241,7 @@ export async function mountHandUI(player, session, env) {
listen(window, "pointercancel", endInput, true);
listen(window, "blur", () => endInput());
}
listen(document, "visibilitychange", scheduleRefresh);
refresh();
diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`);
return { refresh, dispose };
+68 -4
View File
@@ -2,6 +2,56 @@
import { attackHitCount, previewAttackDamage } from "../battle/intent-damage.js";
import { isDeathBlowIntent } from "../battle/death-blow.js";
import { isStunIntent } from "../battle/stun-intent.js";
import { getDiagnostics } from "../../diagnostics/index.js";
const intentPollers = new WeakMap();
const intentFields = ["damage", "hits", "intentType", "summon", "slimed", "infection",
"wound", "ringing", "plow", "dazed", "debuff", "stacks", "block", "strength", "sleep", "shrink"];
export function refreshEnemyIntents(session) {
intentPollers.get(session)?.schedule();
}
function observeIntents(session, document, update) {
let group = intentPollers.get(session);
if (!group) {
const callbacks = new Set();
const host = document.defaultView || globalThis;
let frame = null;
const tick = () => {
if (document.hidden || session.active === false) return;
for (const callback of callbacks) {
try { callback(); }
catch (error) {
getDiagnostics().scope("mengsan.intent").error("refresh.failed", error);
}
}
};
const schedule = () => {
if (frame !== null || document.hidden) return;
const draw = () => { frame = null; tick(); };
frame = host.requestAnimationFrame ? host.requestAnimationFrame(draw) : host.setTimeout(draw, 16);
};
const timer = host.setInterval(tick, 500);
timer?.unref?.();
document.addEventListener?.("visibilitychange", schedule);
group = { callbacks, schedule, dispose() {
host.clearInterval(timer);
if (frame !== null) {
if (host.requestAnimationFrame) host.cancelAnimationFrame(frame);
else host.clearTimeout(frame);
}
document.removeEventListener?.("visibilitychange", schedule);
intentPollers.delete(session);
} };
intentPollers.set(session, group);
}
group.callbacks.add(update);
return () => {
group.callbacks.delete(update);
if (!group.callbacks.size) group.dispose();
};
}
export function attackIconForDamage(damage) {
if (!Number.isInteger(damage) || damage < 0) {
@@ -60,9 +110,9 @@ export function mountEnemyIntent(player, session, assetBase, document,
badge.append(damageLine, debuffLine, blockLine, buffLine);
player.appendChild(badge);
let currentIntent = null;
let timer;
let previous = null, unsubscribe = () => {};
session.ownResource(badge, () => {
clearInterval(timer);
unsubscribe(); previous = null; currentIntent = null;
badge.remove();
});
const refresh = () => {
@@ -180,9 +230,23 @@ export function mountEnemyIntent(player, session, assetBase, document,
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
timer = setInterval(refresh, 100);
const poll = () => {
const values = intentFields.map(key => currentIntent?.[key]);
values.push(player.maxHp);
if (currentIntent?.damage != null) {
const target = getTarget();
const targets = Array.isArray(target) ? target : [target];
for (const current of targets.length ? targets : [null]) {
values.push(previewAttackDamage(currentIntent, player, current));
}
}
if (previous && previous.length === values.length && values.every((value, index) => Object.is(value, previous[index]))) return;
refresh(); previous = values;
};
unsubscribe = observeIntents(session, document, poll);
return intent => {
currentIntent = intent;
refresh();
previous = null;
poll();
};
}
+11 -2
View File
@@ -6,6 +6,7 @@ import { getRelic } from "../../relics/definitions.js";
import { createRelicIcon } from "../relic-icon.js";
import { renderBonds } from "../bonds.js";
import { getSelectableNodes } from "../../progression/state.js";
import { getDiagnostics } from "../../../diagnostics/index.js";
// Original vector pictograms; no external image/font requests.
const iconPaths = {
@@ -187,6 +188,8 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
if (result === "exit") await openModeSelection();
} catch (error) {
console.error(error);
getDiagnostics().scope("mengsan.map").error("map.operation.failed", error,
{ runId: run.runId, revision: run.revision });
await askMenu("暂时无法返回", "征程未被清除,请重试。", [{ id: "close", name: "关闭提示" }]);
} finally { exitButton.disabled = false; }
});
@@ -258,7 +261,9 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
createText("mengsan-map-menu-action-name-shuying", "卡牌库", copy);
createText("mengsan-map-menu-action-description-shuying", `共 ${run.player.deck.length} 张 · 查看花色、点数、属性和卡牌详情`, copy);
library.appendChild(copy);
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); });
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run, {
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); });
parent.appendChild(library);
const appendNames = (title, values, emptyText) => {
createText("mengsan-map-inventory-section-title-shuying", title, parent);
@@ -308,7 +313,10 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
if (back) back.disabled = busy;
}), ".mengsan-bonds-shuying button") });
createMenuAction({ icon: "book", name: "图鉴", description: "卡牌图鉴 · 查看全部梦三卡牌、效果与费用",
onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); }
onClick: () => { closeCardLibrary = openCardLibrary(run, {
view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴",
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); }
}).setAttribute("aria-haspopup", "dialog");
createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => {
const list = ui.create.div(".mengsan-map-record-list-shuying", parent);
@@ -326,6 +334,7 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
try { await openNonameMenu(); }
catch (error) {
console.error("梦三打开 Noname 菜单失败:", error);
getDiagnostics().scope("mengsan.map").error("menu.open.failed", error);
await askMenu("暂时无法显示菜单", error.message || "请稍后重试。", [{ id: "close", name: "回到行军图" }]);
}
},
+6 -1
View File
@@ -1,5 +1,6 @@
import { lib, game, ui, _status } from "../../../../noname.js";
import { save } from "../../../../noname/util/config.js";
import { getDiagnostics } from "../../diagnostics/index.js";
let selectingMode = false;
let nonameMenuTask = null;
@@ -50,7 +51,10 @@ export const openNonameMenu = async () => {
if (initiallyHidden) {
try {
if (!container.classList.contains("hidden")) ui.click.configMenu();
} catch (closeError) { console.error("Noname 菜单关闭失败:", closeError); }
} catch (closeError) {
getDiagnostics().scope("mengsan.navigation").error("menu.close.failed", closeError);
console.error("Noname 菜单关闭失败:", closeError);
}
if (wasPaused) game.pause2();
else game.resume2();
}
@@ -170,6 +174,7 @@ export const mountMenu = (overlay, options = {}) => {
if (confirmed === "confirm") await openModeSelection();
} catch (error) {
console.error("梦三菜单操作失败:", error);
getDiagnostics().scope("mengsan.navigation").error("menu.operation.failed", error);
await askMenu(action === "noname" ? "暂时无法显示菜单" : "暂时无法返回",
action === "noname" ? error.message || "请关闭提示后重试。" : "没有清除征程。请关闭提示后重试。",
[{ id: "close", name: "回到当前界面" }]);
+9 -3
View File
@@ -1,6 +1,8 @@
// 保留完整滚动高度,只挂载可见行与上下各两行;滚动时不重建仍可见的卡牌。
export function createVirtualCardGrid(viewport, grid, createCard) {
const mounted = new Map();
const cached = new Map();
const maxCached = 24;
let records = [], columns = 1, height = 218, gap = 8, padding = 5, viewportHeight = 0;
let frame = 0, disposed = false;
function draw() {
@@ -14,11 +16,14 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
for (const [index, node] of mounted) {
if ((index < first || index >= end) && node !== document.activeElement) {
node.remove(); mounted.delete(index);
cached.set(index, node);
while (cached.size > maxCached) cached.delete(cached.keys().next().value);
}
}
for (let index = first; index < end; index++) {
if (mounted.has(index)) continue;
const node = createCard(records[index], index);
const node = cached.get(index) || createCard(records[index], index);
cached.delete(index);
node.dataset.cardIndex = String(index);
node.style.gridRow = String(Math.floor(index / columns) + 1);
node.style.gridColumn = String(index % columns + 1);
@@ -75,7 +80,7 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
return {
setRecords(value) {
// 调用方更新筛选栏后再统一量尺寸,避免用旧高度先生成一批预览。
records = value; mounted.clear(); grid.replaceChildren();
records = value; mounted.clear(); cached.clear(); grid.replaceChildren();
},
layout,
focus,
@@ -84,7 +89,8 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
disposed = true; cancelAnimationFrame(frame);
viewport.removeEventListener("scroll", schedule);
grid.removeEventListener("keydown", keydown);
mounted.clear(); records = [];
mounted.clear(); cached.clear(); records = [];
grid.replaceChildren();
},
};
}