diff --git a/scripts/api.js b/scripts/api.js index c93bf6c..eff982a 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -147,6 +147,21 @@ export async function completeSession(sessionId) { return result?.message ?? {}; } +export async function getSessionRoster(sessionId) { + if (!sessionId) throw new Error("getSessionRoster requires a session_id."); + const result = await bridgeFetch("get_session_roster", { session_id: sessionId }); + return result?.message ?? {}; +} + +export async function finalizeAttendance(sessionId, attendance) { + if (!sessionId) throw new Error("finalizeAttendance requires a session_id."); + const result = await bridgeFetch("finalize_attendance", { + session_id: sessionId, + attendance: attendance ?? [], + }); + 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 index be15abe..40c241f 100644 --- a/scripts/macros.js +++ b/scripts/macros.js @@ -10,6 +10,8 @@ import { MODULE_ID } from "./constants.js"; import { completeSession, + finalizeAttendance, + getSessionRoster, listScheduledSessions, pushModuleInventory, pushSnapshot, @@ -22,6 +24,124 @@ function escapeHtml(s) { ); } +/** + * 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 ` +
Confirm each player's attendance for this session.
+| Player | +Character(s) | +Status | +
|---|
${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)}).
`, @@ -151,6 +284,30 @@ export async function endSession() { 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); @@ -174,7 +331,7 @@ export async function endSession() { } await ChatMessage.create({ - content: buildSummaryHtml(completion, snapshotResult, moduleResult), + content: buildSummaryHtml(completion, snapshotResult, moduleResult, attendance), speaker: { alias: "Seitime Bridge" }, whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id), });