mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-22 15:19:59 +08:00
Merge origin/main: preserve relic system and diagnostics updates
This commit is contained in:
@@ -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; },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user