/** * 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, finalizeAttendance, getSessionRoster, listScheduledSessions, pushModuleInventory, pushSnapshot, } from "./api.js"; function escapeHtml(s) { return String(s ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); } /** * Activity check used by the attendance proposal: an actor counts as "active" * for this session if its data was modified during the session window, or if * a non-GM owner is currently online when End Session is run. */ function actorWasActive(actor, sessionStartMs) { if (!actor) return false; const mod = actor.system?._stats?.modifiedTime ?? actor._stats?.modifiedTime ?? 0; if (sessionStartMs && mod >= sessionStartMs) return true; const ownership = actor.ownership ?? {}; for (const [userId, level] of Object.entries(ownership)) { if (userId === "default") continue; if (level < CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER) continue; const user = game.users.get(userId); if (user && !user.isGM && user.active) return true; } return false; } /** * Build an attendance proposal from a Frappe roster payload + current * Foundry state. Returns the same player rows with a `proposed_status` * field set: * - Players whose status is anything other than "Signed Up" keep it. * - "Signed Up" players become "Present" if any of their linked * characters' actors show activity this session, else "No Show". * * The proposal is a default-correct starting point for the GM-facing * dialog; every row remains overridable. */ export function proposeAttendance(roster) { const sessionStartMs = roster.session_start ? Date.parse(roster.session_start) : 0; return (roster.players ?? []).map((p) => { if (p.status !== "Signed Up") { return { ...p, proposed_status: p.status }; } const anyActive = (p.characters ?? []).some((c) => actorWasActive(game.actors.get(c.foundry_actor_id), sessionStartMs), ); return { ...p, proposed_status: anyActive ? "Present" : "No Show" }; }); } /** * Show the attendance confirmation dialog. Resolves to the final attendance * array `[{player, status}]` on Confirm, or null on Cancel. An empty roster * resolves to `[]` immediately without showing a dialog. * * The dropdown is restricted to Present / Notice / No Show — every row must * be finalized in this flow. The GM can still rerun End Session later if * they want to revise (the Frappe handler accepts updates). */ async function showAttendanceDialog(proposal) { if (!proposal.length) return []; const STATUS_OPTIONS = ["Present", "Notice", "No Show"]; const rows = proposal .map((p, i) => { const characters = (p.characters ?? []).map((c) => escapeHtml(c.character)).join(", ") || ""; const opts = STATUS_OPTIONS.map( (s) => ``, ).join(""); return ` ${escapeHtml(p.label)} ${characters} `; }) .join(""); return new Promise((resolve) => { new Dialog( { title: "Finalize Attendance", content: `

Confirm each player's attendance for this session.

${rows}
Player Character(s) Status
`, buttons: { cancel: { label: "Cancel", callback: () => resolve(null) }, confirm: { label: "Confirm", callback: (html) => { const result = []; html.find("select[data-player]").each(function () { result.push({ player: $(this).data("player"), status: $(this).val(), }); }); resolve(result); }, }, }, default: "confirm", close: () => resolve(null), }, { width: 560 }, ).render(true); }); } /** * 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 summarizeAttendance(attendance) { const counts = { Present: 0, Notice: 0, "No Show": 0, "Signed Up": 0 }; for (const a of attendance) counts[a.status] = (counts[a.status] ?? 0) + 1; const order = ["Present", "Notice", "No Show", "Signed Up"]; return order .filter((k) => counts[k]) .map((k) => `${counts[k]} ${k}`) .join(", "); } function buildSummaryHtml(completion, snapshotResult, moduleResult, attendance) { const parts = [ `

Session Complete

`, `

${escapeHtml(completion.completed_session)} marked Completed.

`, ]; if (attendance?.length) { parts.push(`

Attendance: ${escapeHtml(summarizeAttendance(attendance))}.

`); } 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:

    `); } if (moduleResult?.upserted) { parts.push( `

    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 roster; try { roster = await getSessionRoster(sessionId); } catch (err) { console.error(`[${MODULE_ID}]`, err); ui.notifications.error(`Could not fetch session roster: ${err.message}`); return; } const proposal = proposeAttendance(roster); const attendance = await showAttendanceDialog(proposal); if (attendance === null) return; // Finalize attendance BEFORE marking the session Completed so that a // failure here leaves the session Scheduled and re-runnable, rather // than Completed with no attendance recorded. try { await finalizeAttendance(sessionId, attendance); } catch (err) { console.error(`[${MODULE_ID}]`, err); ui.notifications.error(`Could not finalize attendance: ${err.message}`); 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, attendance), 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}.`); }