/** * GM-facing orchestration: the End Session flow and the bulk * snapshot push it depends on. Imported by main.js and exposed on * `globalThis.seitimeBridge` for use from Foundry macros. * * Recommended macro body (Script type, no arguments): * seitimeBridge.endSession(); */ import { MODULE_ID } from "./constants.js"; import { completeSession, listScheduledSessions, pushModuleInventory, pushSnapshot, } from "./api.js"; function escapeHtml(s) { return String(s ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); } /** * Push snapshots for every PC in the world, in parallel. Returns a summary * with successful/failed counts and the names of any failures so the caller * can surface them. */ export async function pushAllSnapshots() { const pcs = game.actors.filter((a) => a.type === "character"); const results = await Promise.allSettled(pcs.map((a) => pushSnapshot(a))); const failed = results .map((r, i) => (r.status === "rejected" ? { name: pcs[i].name, error: r.reason?.message ?? String(r.reason) } : null)) .filter(Boolean); return { total: pcs.length, successful: pcs.length - failed.length, failed, }; } /** * Show the appropriate dialog for the number of candidates and resolve to * the chosen session_id, or null if the user cancels. */ async function pickSession(sessions) { if (sessions.length === 1) { const s = sessions[0]; const confirmed = await Dialog.confirm({ title: "End Session", content: `
End ${escapeHtml(s.session_title)}?
This will mark it Completed, schedule the next session, and push snapshots for all PCs.
`, yes: () => true, no: () => false, defaultYes: true, }); return confirmed ? s.session_id : null; } const options = sessions .map( (s) => ``, ) .join(""); return new Promise((resolve) => { new Dialog({ title: "End Session", content: `Multiple scheduled sessions match this Foundry world. Pick one to end:
`, buttons: { cancel: { label: "Cancel", callback: () => resolve(null) }, end: { label: "End Session", callback: (html) => { const value = html.find("#seitime-session-pick").val(); resolve(value || null); }, }, }, default: "end", close: () => resolve(null), }).render(true); }); } function buildSummaryHtml(completion, snapshotResult, moduleResult) { const parts = [ `${escapeHtml(completion.completed_session)} marked Completed.
`, ]; if (completion.next_session) { parts.push( `Next session scheduled: ${escapeHtml(completion.next_session)} (#${escapeHtml(completion.next_session_number)}).
`, ); } parts.push( `Snapshots pushed: ${snapshotResult.successful} of ${snapshotResult.total}.
`, ); if (snapshotResult.failed.length) { const items = snapshotResult.failed .map((f) => `Failed pushes:
Tracked ${moduleResult.upserted} active module(s) for credits.
`, ); } else if (moduleResult?.error) { parts.push( `Module inventory push failed: ${escapeHtml(moduleResult.error)}
`, ); } return parts.join(""); } /** * Full end-of-session flow. Invoked from a Foundry macro by the GM. */ export async function endSession() { if (!game.user.isGM) { ui.notifications.warn("End Session is GM-only."); return; } let sessions; try { sessions = await listScheduledSessions(); } catch (err) { console.error(`[${MODULE_ID}]`, err); ui.notifications.error(`Could not fetch scheduled sessions: ${err.message}`); return; } if (!sessions.length) { ui.notifications.warn( "No scheduled sessions found for this Foundry world. Check that the world ID matches a Game in Frappe.", ); return; } const sessionId = await pickSession(sessions); if (!sessionId) return; let completion; try { completion = await completeSession(sessionId); } catch (err) { console.error(`[${MODULE_ID}]`, err); ui.notifications.error(`Could not complete session: ${err.message}`); return; } const snapshotResult = await pushAllSnapshots(); // Module inventory is non-critical — log and continue if the push fails // so the session still gets marked Completed with a clean summary. let moduleResult = null; try { const raw = await pushModuleInventory(); moduleResult = raw?.message ?? null; } catch (err) { console.warn(`[${MODULE_ID}] module inventory push failed:`, err); moduleResult = { error: err.message }; } await ChatMessage.create({ content: buildSummaryHtml(completion, snapshotResult, moduleResult), speaker: { alias: "Seitime Bridge" }, whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id), }); const failureNote = snapshotResult.failed.length ? ` (${snapshotResult.failed.length} snapshot push(es) failed — see chat)` : ""; ui.notifications.info(`Session ended. ${snapshotResult.successful}/${snapshotResult.total} snapshots pushed${failureNote}.`); }