Skip to main content

IStoreManager pattern

IStoreManager is the only layer in sr3e allowed to talk to Foundry's actor/item/effect APIs. Svelte components never call document.update() — they read and write through a store returned by StoreManager.Instance, and the singleton keeps that store in sync with the underlying document via Foundry hooks.

Getting a manager

There is one StoreManager for the whole game, not one per document:

import { StoreManager } from "../../utilities/StoreManager.svelte";

const storeManager = StoreManager.Instance;

Subscribing

Before creating any store for a document, subscribe to it. This registers Foundry hooks (update<DocType>, Active Effect create/update/delete, and — for actors — actorSystemRecalculated) the first time a document is subscribed, and reference-counts every subsequent call:

storeManager.Subscribe(actor);
// ... later, typically in onDestroy ...
storeManager.Unsubscribe(actor);

Hooks and cached stores for a document are only torn down once its subscriber count reaches zero — safe to call Subscribe from every component that touches the document without worrying about duplicate hook registration.

Store types

MethodDirectionPersisted?Use for
GetRWStore(doc, path, isRoot?)UI ↔ DocumentYesEditable fields (system.attributes.body)
GetROStore(doc, path, isRoot?)Document → UIYes (read side)Computed/derived fields the UI shouldn't edit
GetSimpleStatROStore(doc, path)Document → UIYes (read side).value + .mod sum for a SimpleStat, cached per document+path
GetShallowStore(doc, name, initial)UI onlyNoTransient UI state (accordion open/closed, active tab)
GetFlagStore(doc, flagName, initial)UI ↔ flags.sr3e.*YesPersisted user preferences

path omits the system. prefix by default — GetRWStore(actor, "attributes.body", false) reads/writes actor.system.attributes.body. Pass isRoot: true to use the path as-is (e.g. for flags or other root-level fields addressed through GetRWStore directly).

// Editable attribute, bidirectional sync
const bodyStore = storeManager.GetRWStore<number>(actor, "attributes.body", false);

// Attribute + Active Effect bonus, summed and cached
const totalBodyStore = storeManager.GetSimpleStatROStore(actor, "attributes.body");

// Accordion state, never touches the document
const expandedStore = storeManager.GetShallowStore<boolean>(actor, "skillsExpanded", true);

// Persisted preference
const themeStore = storeManager.GetFlagStore<string>(actor, "theme", "dark");

Why GetSimpleStatROStore exists

Attributes and pools can carry both a base .value and a .mod from Active Effects. A naive derived store built directly from an effect-bearing field risks infinite recursion when the effect itself depends on the recalculated total. GetSimpleStatROStore sidesteps this by deriving from the two underlying GetRWStore calls (${path}.value, ${path}.mod) instead, and caches the result per document+path so repeated calls for the same stat return the same store.

What it won't do for you

IStoreManager provides reactive access and writes back via document.update() — it does not validate values or enforce rules. Validation and business logic belong in the service layer (procedures, spending services), which read/write through the same stores rather than bypassing them.

Full usage example

import { onDestroy } from "svelte";
import { StoreManager } from "../../utilities/StoreManager.svelte";

const storeManager = StoreManager.Instance;
storeManager.Subscribe(actor);

const bodyStore = storeManager.GetRWStore<number>(actor, "attributes.body", false);

onDestroy(() => storeManager.Unsubscribe(actor));
<input type="number" bind:value={$bodyStore} />

See module/utilities/StoreManager.example.ts for further runnable examples covering reference counting and multi-document usage.