seitime-bridge/scripts/main.js
Vassili Minaev c7159ee84b End Session macro flow
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 <noreply@anthropic.com>
2026-05-10 02:51:01 -06:00

109 lines
3.4 KiB
JavaScript

/**
* Entry point for seitime-bridge. Registers settings, wires hooks, and
* exposes the public API on `globalThis.seitimeBridge` for use from
* Foundry macros and the F12 console.
*
* All push operations are gated to the primary connected GM
* (`game.users.activeGM`) so that worlds with multiple GMs don't
* duplicate webhook calls.
*/
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();
function isPrimaryGM() {
return game.user.isGM && game.users.activeGM?.id === game.user.id;
}
function safePushManifest(reason) {
if (!isPrimaryGM()) return;
pushManifest().catch((err) => {
console.warn(`[${MODULE_ID}] manifest push (${reason}) failed:`, err);
});
}
/**
* Schedule a debounced snapshot push for an actor. Each actor has its own
* debouncer so that rapid edits to one PC don't delay pushes for others.
*
* Gated to the primary GM regardless of who made the change — Foundry
* broadcasts updates to all clients, so the GM is always in a position
* to push and we avoid duplicate pushes from co-GMs that way.
*/
function scheduleSnapshotPush(actor) {
if (!isPrimaryGM()) return;
if (!game.settings.get(MODULE_ID, "snapshotAutoSync")) return;
let debouncer = snapshotDebouncers.get(actor.id);
if (!debouncer) {
const debounceMs = game.settings.get(MODULE_ID, "snapshotDebounceMs");
debouncer = foundry.utils.debounce((a) => {
pushSnapshot(a).catch((err) => {
console.warn(`[${MODULE_ID}] snapshot push for ${a.name} failed:`, err);
});
}, debounceMs);
snapshotDebouncers.set(actor.id, debouncer);
}
debouncer(actor);
}
function actorFromItem(item) {
return item.parent?.documentName === "Actor" ? item.parent : null;
}
Hooks.once("init", () => {
registerSettings();
});
Hooks.once("ready", () => {
const mod = game.modules.get(MODULE_ID);
const api = { pushManifest, pushSnapshot, testConnection, endSession, pushAllSnapshots };
mod.api = api;
globalThis.seitimeBridge = api;
console.log(`[${MODULE_ID}] ready. Call seitimeBridge.testConnection() to verify config, or seitimeBridge.endSession() from a macro.`);
safePushManifest("ready");
const intervalMin = game.settings.get(MODULE_ID, "manifestSyncIntervalMinutes");
if (intervalMin > 0) {
manifestTimerId = setInterval(() => safePushManifest("periodic"), intervalMin * 60 * 1000);
}
});
Hooks.on("createActor", (actor) => {
if (actor.type !== "character") return;
safePushManifest("createActor");
});
Hooks.on("deleteActor", (actor) => {
if (actor.type !== "character") return;
safePushManifest("deleteActor");
});
Hooks.on("updateActor", (actor) => {
if (actor.type !== "character") return;
scheduleSnapshotPush(actor);
});
// Item edits don't fire updateActor in v12, so listen to the embedded
// item hooks directly for inventory/feature/class changes.
Hooks.on("createItem", (item) => {
const actor = actorFromItem(item);
if (actor?.type === "character") scheduleSnapshotPush(actor);
});
Hooks.on("updateItem", (item) => {
const actor = actorFromItem(item);
if (actor?.type === "character") scheduleSnapshotPush(actor);
});
Hooks.on("deleteItem", (item) => {
const actor = actorFromItem(item);
if (actor?.type === "character") scheduleSnapshotPush(actor);
});