From 2b7fb976963773c9a2705c889320330a2a03e2f9 Mon Sep 17 00:00:00 2001 From: Vassili Minaev Date: Sun, 10 May 2026 02:43:09 -0600 Subject: [PATCH] Snapshot push on actor and item edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundry side of the per-PC snapshot flow: the primary GM pushes a debounced snapshot of any character that gets edited, including embedded item changes (updateItem/createItem/deleteItem don't trigger updateActor in v12). Payload is actor.toObject() augmented with a denormalized _player_name field — the Frappe extractor reads it from there since Foundry's ownership map uses opaque user IDs. Per-actor debouncers (default 5s, configurable) keep combat HP edits from spamming the webhook. Toggle off via the snapshotAutoSync setting if you want explicit-only pushes from a macro. API exposed at globalThis.seitimeBridge.pushSnapshot(actor) for manual pushes (single argument: a Foundry Actor document). Co-Authored-By: Claude Opus 4.7 --- scripts/api.js | 25 ++++++++++++++++++++ scripts/main.js | 56 +++++++++++++++++++++++++++++++++++++++++++-- scripts/settings.js | 19 +++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/scripts/api.js b/scripts/api.js index 59c8fce..5a10fce 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -79,6 +79,31 @@ export async function pushManifest() { 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; +} + /** * 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/main.js b/scripts/main.js index a3f41e5..e3ab7f4 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -10,9 +10,10 @@ import { MODULE_ID } from "./constants.js"; import { registerSettings } from "./settings.js"; -import { pushManifest, testConnection } from "./api.js"; +import { pushManifest, pushSnapshot, testConnection } from "./api.js"; let manifestTimerId = null; +const snapshotDebouncers = new Map(); function isPrimaryGM() { return game.user.isGM && game.users.activeGM?.id === game.user.id; @@ -25,13 +26,42 @@ function safePushManifest(reason) { }); } +/** + * 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, testConnection }; + const api = { pushManifest, pushSnapshot, testConnection }; mod.api = api; globalThis.seitimeBridge = api; @@ -54,3 +84,25 @@ 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); +}); diff --git a/scripts/settings.js b/scripts/settings.js index b40f6f7..5ab7470 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -49,4 +49,23 @@ export function registerSettings() { default: 15, range: { min: 0, max: 120, step: 1 }, }); + + game.settings.register(MODULE_ID, "snapshotDebounceMs", { + name: "Snapshot Debounce (ms)", + hint: "How long to wait after the last actor edit before pushing a snapshot. Combat causes frequent edits, so a few seconds is usually right.", + scope: "world", + config: true, + type: Number, + default: 5000, + range: { min: 500, max: 60000, step: 500 }, + }); + + game.settings.register(MODULE_ID, "snapshotAutoSync", { + name: "Auto-Sync on Actor Edits", + hint: "When enabled, the primary GM pushes a snapshot every time a PC is edited (debounced). Disable to push only via macro/console.", + scope: "world", + config: true, + type: Boolean, + default: true, + }); }