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;
|
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
|
* Round-trip an empty manifest push to verify URL, secret, world ID, and
|
||||||
* Frappe-side enablement. Surfaces the result in the Foundry UI.
|
* Frappe-side enablement. Surfaces the result in the Foundry UI.
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@
|
||||||
|
|
||||||
import { MODULE_ID } from "./constants.js";
|
import { MODULE_ID } from "./constants.js";
|
||||||
import { registerSettings } from "./settings.js";
|
import { registerSettings } from "./settings.js";
|
||||||
import { pushManifest, testConnection } from "./api.js";
|
import { pushManifest, pushSnapshot, testConnection } from "./api.js";
|
||||||
|
|
||||||
let manifestTimerId = null;
|
let manifestTimerId = null;
|
||||||
|
const snapshotDebouncers = new Map();
|
||||||
|
|
||||||
function isPrimaryGM() {
|
function isPrimaryGM() {
|
||||||
return game.user.isGM && game.users.activeGM?.id === game.user.id;
|
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", () => {
|
Hooks.once("init", () => {
|
||||||
registerSettings();
|
registerSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
Hooks.once("ready", () => {
|
Hooks.once("ready", () => {
|
||||||
const mod = game.modules.get(MODULE_ID);
|
const mod = game.modules.get(MODULE_ID);
|
||||||
const api = { pushManifest, testConnection };
|
const api = { pushManifest, pushSnapshot, testConnection };
|
||||||
mod.api = api;
|
mod.api = api;
|
||||||
globalThis.seitimeBridge = api;
|
globalThis.seitimeBridge = api;
|
||||||
|
|
||||||
|
|
@ -54,3 +84,25 @@ Hooks.on("deleteActor", (actor) => {
|
||||||
if (actor.type !== "character") return;
|
if (actor.type !== "character") return;
|
||||||
safePushManifest("deleteActor");
|
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,
|
default: 15,
|
||||||
range: { min: 0, max: 120, step: 1 },
|
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