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:
parent
bc3aa10ab2
commit
2b7fb97696
3 changed files with 98 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue