endSession() now pulls the session roster from Frappe, computes a default-correct attendance proposal from Foundry actor activity, and presents it as a dropdown grid the GM can override. Activity = actor.system._stats.modifiedTime since session_start, OR a non-GM owner currently online. Notice rows are preserved as-is so the player's stated intent is respected; the GM can still flip them manually in the dialog. finalize_attendance runs BEFORE complete_session so a failure there leaves the session Scheduled and re-runnable rather than Completed- with-no-attendance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
180 lines
6.1 KiB
JavaScript
180 lines
6.1 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;
|
|
}
|
|
|
|
/**
|
|
* 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 ?? {};
|
|
}
|
|
|
|
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.
|
|
*/
|
|
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;
|
|
}
|
|
}
|