/** * 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; } /** * Project each active Foundry module to a credit-only summary. Versions * are deliberately omitted: this list is intended for a public credits * page on the Frappe side and we don't want to leak dependency versions * if the site is ever indexed. The Frappe endpoint also strips any * version/compatibility fields server-side as defense-in-depth. */ function buildModuleInventory() { const out = []; for (const mod of game.modules.values()) { if (!mod.active) continue; const authors = (mod.authors || []) .map((a) => ({ name: a?.name, url: a?.url || null })) .filter((a) => a.name); out.push({ id: mod.id, title: mod.title || mod.id, url: mod.url || null, authors, }); } return out; } export async function pushModuleInventory() { const modules = buildModuleInventory(); console.log(`[${MODULE_ID}] pushing module inventory (${modules.length} active)`); const result = await bridgeFetch("receive_module_inventory", { modules }); console.log(`[${MODULE_ID}] module inventory 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; } }