mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-13 04:20:03 +08:00
140 lines
7.6 KiB
JavaScript
140 lines
7.6 KiB
JavaScript
// 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 () => {}, 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 step("drain", () => active.session.drain());
|
|
// Allow mode-start/parent frames and foreign pauses to unwind; never resume them.
|
|
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 step("victoryDialogue", () => presentVictory(copy(job.pending)), false);
|
|
job.victoryPresented = true;
|
|
}
|
|
if (job.pending.state !== "chosen") {
|
|
state = "choosing";
|
|
if (!job.hasChoice) {
|
|
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 step("applyChoice", () => settlement.choose(job.input.run.runId, job.pending.id, job.choiceId));
|
|
}
|
|
if (!job.committed) {
|
|
state = "committing";
|
|
job.committed = await step("commit", () => settlement.commit(job.input.run.runId, job.pending.id));
|
|
}
|
|
if (!job.skillsReleased) {
|
|
state = "releasingSkills";
|
|
await step("releaseSkills", releaseSkills);
|
|
job.skillsReleased = true;
|
|
await step("quiesce.afterSkills", quiesce);
|
|
}
|
|
state = "cleaning";
|
|
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 step("showMap", () => showMap(mapRun), false);
|
|
job.committed.run = mapRun;
|
|
}
|
|
else if (job.committed.route !== "new") {
|
|
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.
|
|
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");
|
|
const saved = settlement.readRun();
|
|
if (!saved || saved.runId !== run.runId || saved.revision !== run.revision) throw new Error("Stale battle input");
|
|
state = "starting";
|
|
try {
|
|
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"; 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) {
|
|
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; },
|
|
});
|
|
}
|
|
|
|
// 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");
|
|
}
|