Snapshot push on actor and item edits

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 <noreply@anthropic.com>
This commit is contained in:
Vassili Minaev 2026-05-10 02:43:09 -06:00
parent bc3aa10ab2
commit 2b7fb97696
3 changed files with 98 additions and 2 deletions

View file

@ -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.

View file

@ -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);
});

View file

@ -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,
});
}