From c7159ee84b923e2fb24c2c71d57792e1d03b69b3 Mon Sep 17 00:00:00 2001 From: Vassili Minaev Date: Sun, 10 May 2026 02:51:01 -0600 Subject: [PATCH] End Session macro flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the GM-facing end-of-session orchestration. The expected use is a Foundry macro of type Script with body: seitimeBridge.endSession(); Flow: 1. Fetch scheduled sessions for this world from Frappe. 2. If 0 → notify and abort. If 1 → confirm dialog. If 2+ → picker dialog. 3. POST complete_session to mark the chosen session Completed and schedule the next one (Frappe-side schedule_next_session). 4. Push snapshots for every PC in parallel (Promise.allSettled — partial failures are surfaced, not fatal). 5. Whisper a summary chat message to the GM (counts and any failed actor names with errors). scripts/macros.js holds the orchestration; scripts/api.js gains thin fetch wrappers for list_scheduled_sessions and complete_session. HTML in dialogs and chat is escaped on output since session titles ultimately come from user input. Public API now: testConnection, pushManifest, pushSnapshot, pushAllSnapshots, endSession. Co-Authored-By: Claude Opus 4.7 --- scripts/api.js | 11 ++++ scripts/macros.js | 163 ++++++++++++++++++++++++++++++++++++++++++++++ scripts/main.js | 5 +- 3 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 scripts/macros.js diff --git a/scripts/api.js b/scripts/api.js index 5a10fce..a4bd19e 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -104,6 +104,17 @@ export async function pushSnapshot(actor) { return result; } +export async function listScheduledSessions() { + const result = await bridgeFetch("list_scheduled_sessions", {}); + return result?.message ?? []; +} + +export async function completeSession(sessionId) { + if (!sessionId) throw new Error("completeSession requires a session_id."); + const result = await bridgeFetch("complete_session", { session_id: sessionId }); + return result?.message ?? {}; +} + /** * Round-trip an empty manifest push to verify URL, secret, world ID, and * Frappe-side enablement. Surfaces the result in the Foundry UI. diff --git a/scripts/macros.js b/scripts/macros.js new file mode 100644 index 0000000..5c70c68 --- /dev/null +++ b/scripts/macros.js @@ -0,0 +1,163 @@ +/** + * 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, + 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) { + const parts = [ + `

Session Complete

`, + `

${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) => `
  • ${escapeHtml(f.name)}: ${escapeHtml(f.error)}
  • `) + .join(""); + parts.push(`

    Failed pushes:

    `); + } + 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(); + + await ChatMessage.create({ + content: buildSummaryHtml(completion, snapshotResult), + 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}.`); +} diff --git a/scripts/main.js b/scripts/main.js index e3ab7f4..06abc0b 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -11,6 +11,7 @@ import { MODULE_ID } from "./constants.js"; import { registerSettings } from "./settings.js"; import { pushManifest, pushSnapshot, testConnection } from "./api.js"; +import { endSession, pushAllSnapshots } from "./macros.js"; let manifestTimerId = null; const snapshotDebouncers = new Map(); @@ -61,11 +62,11 @@ Hooks.once("init", () => { Hooks.once("ready", () => { const mod = game.modules.get(MODULE_ID); - const api = { pushManifest, pushSnapshot, testConnection }; + const api = { pushManifest, pushSnapshot, testConnection, endSession, pushAllSnapshots }; mod.api = api; globalThis.seitimeBridge = api; - console.log(`[${MODULE_ID}] ready. Call seitimeBridge.testConnection() to verify config.`); + console.log(`[${MODULE_ID}] ready. Call seitimeBridge.testConnection() to verify config, or seitimeBridge.endSession() from a macro.`); safePushManifest("ready");