seitime-bridge/scripts/api.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

133 lines
4.6 KiB
JavaScript

/**
* Outbound HTTP to the Frappe `st.api.foundry.*` endpoints.
*
* All calls are POST with `X-Bridge-Secret` and `X-Bridge-World` headers.
* Bodies are JSON. Errors include the Frappe server message when present.
*/
import { MODULE_ID } from "./constants.js";
function getConfig() {
const baseUrl = (game.settings.get(MODULE_ID, "frappeBaseUrl") || "").replace(/\/+$/, "");
const secret = game.settings.get(MODULE_ID, "sharedSecret") || "";
const worldId = game.settings.get(MODULE_ID, "worldId") || game.world?.id || "";
return { baseUrl, secret, worldId };
}
async function bridgeFetch(method, body) {
const { baseUrl, secret, worldId } = getConfig();
if (!baseUrl) throw new Error("Bridge not configured: set Frappe Base URL in module settings.");
if (!secret) throw new Error("Bridge not configured: set Shared Secret in module settings.");
if (!worldId) throw new Error("Bridge not configured: World ID could not be determined.");
const url = `${baseUrl}/api/method/st.api.foundry.${method}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bridge-Secret": secret,
"X-Bridge-World": worldId,
},
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = "";
try {
const json = await res.json();
detail = json._server_messages || json.exception || JSON.stringify(json);
} catch {
detail = await res.text();
}
throw new Error(`Bridge ${method} failed: ${res.status} ${res.statusText}${detail}`);
}
return await res.json();
}
/**
* Produce a player display name for an actor by inspecting Foundry's
* ownership map. Returns the first non-GM user with OWNER permission, or
* null if none exists. The Frappe side reads this from `_player_name`
* during snapshot extraction (see `_actor_player_name` in foundry.py).
*/
function resolvePlayerName(actor) {
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) return user.name;
}
return null;
}
function buildManifest() {
return game.actors
.filter((a) => a.type === "character")
.map((a) => ({
id: a.id,
name: a.name,
player: resolvePlayerName(a),
}));
}
export async function pushManifest() {
const actors = buildManifest();
console.log(`[${MODULE_ID}] pushing manifest with ${actors.length} actor(s)`);
const result = await bridgeFetch("receive_actor_index", { actors });
console.log(`[${MODULE_ID}] manifest result:`, result);
return result;
}
/**
* Push one actor's full data to the Frappe side. We send `toObject()`
* (source data, pre-ActiveEffects) since the fields the Frappe extractor
* currently reads — XP, HP, class levels, inventory — are stored, not
* derived. If we later want effect-applied values (e.g. ability scores
* with magic-item bonuses), we'll add a `_derived` block.
*
* `_player_name` is denormalized here because Foundry's ownership map
* uses opaque user IDs that don't survive the trip to Frappe.
*/
export async function pushSnapshot(actor) {
if (!actor) throw new Error("pushSnapshot called with no actor.");
if (actor.type !== "character") {
throw new Error(`Actor "${actor.name}" is not a PC (type=${actor.type}).`);
}
const payload = {
...actor.toObject(),
_player_name: resolvePlayerName(actor),
};
console.log(`[${MODULE_ID}] pushing snapshot for ${actor.name} (${actor.id})`);
const result = await bridgeFetch("receive_actor_snapshot", payload);
console.log(`[${MODULE_ID}] snapshot result:`, result);
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.
*/
export async function testConnection() {
console.log(`[${MODULE_ID}] testing connection...`);
try {
const result = await bridgeFetch("receive_actor_index", { actors: [] });
const received = result?.message?.received ?? 0;
ui.notifications.info(`Seitime Bridge OK — server accepted ${received} actor(s).`);
return result;
} catch (err) {
ui.notifications.error(`Seitime Bridge: ${err.message}`);
throw err;
}
}