@vttforge/core 0.0.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,911 @@
1
+ //#region src/errors/registry.ts
2
+ const DOCS_BASE_URL = "https://vttforge.dev/errors";
3
+ const REGISTRY = Object.freeze({
4
+ "VTTF-0001": Object.freeze({
5
+ code: "VTTF-0001",
6
+ name: "SystemAlreadyRegistered",
7
+ summary: "registerSystem() was called more than once for the same system id. This is almost always a hot-reload artefact or a duplicate import."
8
+ }),
9
+ "VTTF-0002": Object.freeze({
10
+ code: "VTTF-0002",
11
+ name: "MissingFoundryGlobals",
12
+ summary: "VTTForge code ran in an environment without Foundry globals (game, Hooks, CONFIG). Initialise inside the Foundry runtime, not in a Node test without mocks."
13
+ }),
14
+ "VTTF-0003": Object.freeze({
15
+ code: "VTTF-0003",
16
+ name: "UnknownSetting",
17
+ summary: "SystemConfig.get() / set() was called with a key that was never passed to SystemConfig.register(). Register the setting in your init hook before reading it."
18
+ }),
19
+ "VTTF-0004": Object.freeze({
20
+ code: "VTTF-0004",
21
+ name: "MigrationFailed",
22
+ summary: "A migration function passed to createMigrationRunner() threw. The original error is available on .cause. The schemaVersion setting is not advanced past the failed migration so retrying on the next world load picks up where the failure left off."
23
+ }),
24
+ "VTTF-0005": Object.freeze({
25
+ code: "VTTF-0005",
26
+ name: "WorldTooOldForMigration",
27
+ summary: "createMigrationRunner() was called on a world whose stored schemaVersion is older than the configured compatibleVersion floor. Upgrade the world to a supported intermediate version before continuing — running migrations across the gap would corrupt data."
28
+ })
29
+ });
30
+ /**
31
+ * Look up a registered entry by code. Throws if the code is unknown — the
32
+ * registry is the source of truth, so missing codes mean a typo.
33
+ */
34
+ function getErrorEntry(code) {
35
+ const entry = REGISTRY[code];
36
+ if (entry === void 0) throw new Error(`Unknown VTTForge error code: ${code}. Add it to the registry.`);
37
+ return entry;
38
+ }
39
+ /**
40
+ * Return every entry currently in the registry. Used by codegen to emit the
41
+ * runtime constants and the JSON manifest that powers the docs pages.
42
+ */
43
+ function listErrorEntries() {
44
+ return Object.values(REGISTRY);
45
+ }
46
+ function docsUrlFor(code) {
47
+ return `${DOCS_BASE_URL}/${code}`;
48
+ }
49
+ /**
50
+ * VttfError — every error VTTForge throws extends this.
51
+ *
52
+ * - `code` is the registry key (string-narrowed).
53
+ * - `name` is the PascalCase name from the registry — shows up in stack traces.
54
+ * - `docsUrl` points at the docs page.
55
+ * - `cause` uses the native ES2022 mechanism. Multiple causes => pass an
56
+ * `AggregateError` as the cause.
57
+ */
58
+ var VttfError = class extends Error {
59
+ code;
60
+ docsUrl;
61
+ constructor(code, message, options) {
62
+ const entry = getErrorEntry(code);
63
+ const finalMessage = `[${code}] ${message ?? entry.summary}`;
64
+ super(finalMessage, options);
65
+ this.code = code;
66
+ this.name = entry.name;
67
+ this.docsUrl = docsUrlFor(code);
68
+ }
69
+ };
70
+ //#endregion
71
+ //#region src/base-actor-sheet.ts
72
+ /**
73
+ * BaseActorSheet — `ActorSheetV2 + HandlebarsApplicationMixin` baseline with the
74
+ * boilerplate every shipping system copy-pastes hoisted into the SDK.
75
+ *
76
+ * What this adds beyond stock Foundry v13:
77
+ *
78
+ * - **`static DRAG_DROP`** — declare drag sources / drop targets as data, get
79
+ * `foundry.applications.ux.DragDrop` instances wired in `_onRender` with
80
+ * `isEditable`-gated permissions and a sensible default `_onDragStart` that
81
+ * serialises `data-item-id` elements as `{ type: "Item", uuid }`.
82
+ * - **`_prepareContext` auto-fills `context.tabs[group]`** for every group
83
+ * declared in ApplicationV2's `static TABS`, so subclass `_prepareContext`
84
+ * implementations stop having to call `_prepareTabs(group)` by hand.
85
+ * - **Typed drop dispatch** — override `onDropItem(item, event)` /
86
+ * `onDropActor(actor, event)` / `onDropFolder(folder, event)` /
87
+ * `onDropActiveEffect(effect, event)` and skip the `fromUuid()` ceremony.
88
+ * Returning `undefined` falls through to Foundry's default `_onDropX`
89
+ * behaviour; return any other value to take ownership.
90
+ *
91
+ * Intentional non-additions:
92
+ *
93
+ * - `editImage` action — already shipped by `DocumentSheetV2` (inherited by
94
+ * `ActorSheetV2`). Templates wire `<img data-edit="img">` and Foundry's
95
+ * built-in action handles the `FilePicker` flow.
96
+ * - `_getTabs()` — ApplicationV2 already owns the tab state machine; we only
97
+ * eliminate the `_prepareTabs` call in `_prepareContext`.
98
+ *
99
+ * Resolved lazily so subclasses can be declared at module load without
100
+ * Foundry globals existing yet (test boot, ESM hoist).
101
+ */
102
+ function resolveBases$1() {
103
+ const foundry = globalThis.foundry;
104
+ const Base = foundry?.applications?.sheets?.ActorSheetV2;
105
+ const mixin = foundry?.applications?.api?.HandlebarsApplicationMixin;
106
+ if (typeof Base !== "function" || typeof mixin !== "function") throw new VttfError("VTTF-0002", "foundry.applications.sheets.ActorSheetV2 and/or foundry.applications.api.HandlebarsApplicationMixin are not available. Define your BaseActorSheet subclasses inside the Foundry runtime (or stub the global in tests).");
107
+ return {
108
+ Base,
109
+ mixin
110
+ };
111
+ }
112
+ function resolveDragDrop$1() {
113
+ const ctor = globalThis.foundry?.applications?.ux?.DragDrop;
114
+ return typeof ctor === "function" ? ctor : void 0;
115
+ }
116
+ async function resolveFromUuid(uuid) {
117
+ const fn = globalThis.fromUuid;
118
+ if (typeof fn !== "function") return null;
119
+ return fn(uuid);
120
+ }
121
+ /**
122
+ * Marker class that consumer CSS uses for scoping. Always present on every
123
+ * VTTForge-derived sheet so rules like `.vttforge .actor-sheet { ... }` work.
124
+ */
125
+ const VTTFORGE_SHEET_CLASS = "vttforge";
126
+ /**
127
+ * Build the `BaseActorSheet` for the current Foundry runtime. See module
128
+ * header for the boilerplate this base eliminates.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * class CharacterSheet extends BaseActorSheet() {
133
+ * static DEFAULT_OPTIONS = foundry.utils.mergeObject(
134
+ * super.DEFAULT_OPTIONS,
135
+ * { classes: ['my-system'], position: { width: 720 } },
136
+ * );
137
+ * static PARTS = { ... };
138
+ * static TABS = {
139
+ * primary: {
140
+ * tabs: [
141
+ * { id: 'features', group: 'primary', label: 'Features' },
142
+ * { id: 'inventory', group: 'primary', label: 'Inventory' },
143
+ * ],
144
+ * initial: 'features',
145
+ * },
146
+ * };
147
+ * static DRAG_DROP = [{ dragSelector: '.item[draggable=true]', dropSelector: null }];
148
+ * async onDropItem(item, event) {
149
+ * if (item.type !== 'weapon') return false;
150
+ * // …fall through to super by returning undefined.
151
+ * }
152
+ * }
153
+ * ```
154
+ */
155
+ function BaseActorSheet() {
156
+ const { Base, mixin } = resolveBases$1();
157
+ const Mixed = mixin(Base);
158
+ class VttforgeBaseActorSheet extends Mixed {
159
+ static DEFAULT_OPTIONS = {
160
+ classes: [VTTFORGE_SHEET_CLASS],
161
+ window: { resizable: true },
162
+ position: {
163
+ width: 600,
164
+ height: 700
165
+ },
166
+ tag: "form",
167
+ form: {
168
+ submitOnChange: true,
169
+ closeOnSubmit: false
170
+ },
171
+ actions: { vttforgeTab: VttforgeBaseActorSheet._onTab }
172
+ };
173
+ /**
174
+ * Declarative DragDrop entries. Each becomes a
175
+ * `foundry.applications.ux.DragDrop` instance bound in `_onRender`.
176
+ * Subclasses override by re-declaring `static DRAG_DROP = [...]`.
177
+ */
178
+ static DRAG_DROP = [];
179
+ /**
180
+ * Augment ApplicationV2's context with `tabs[group]` for sheets that
181
+ * declare **multiple** `static TABS` groups. ApplicationV2 already
182
+ * auto-populates `context.tabs` (keyed by tab id) for single-group
183
+ * sheets — overriding that flat shape would force every consumer to
184
+ * either unwrap or write `context.tabs.<group>.<tabId>` in templates.
185
+ * Multi-group sheets get nested `context.tabs.<group>.<tabId>` because
186
+ * ApplicationV2 returns `{}` for them by default.
187
+ */
188
+ async _prepareContext(options) {
189
+ const superPrepare = Mixed.prototype._prepareContext;
190
+ const context = typeof superPrepare === "function" ? await superPrepare.call(this, options) : {};
191
+ const tabsConfig = this.constructor.TABS;
192
+ if (tabsConfig && typeof tabsConfig === "object") {
193
+ const groups = Object.keys(tabsConfig);
194
+ if (groups.length > 1) {
195
+ const prepareTabs = this._prepareTabs;
196
+ const tabs = {};
197
+ for (const group of groups) tabs[group] = typeof prepareTabs === "function" ? prepareTabs.call(this, group) : {};
198
+ context.tabs = tabs;
199
+ }
200
+ }
201
+ return context;
202
+ }
203
+ /**
204
+ * Default `tab` action handler. ApplicationV2 doesn't ship one, so every
205
+ * sheet that uses `<button data-action="tab" data-tab=… data-group=…>`
206
+ * has to wire its own. We toggle the `.active` class on the matching
207
+ * nav element (`[data-action="tab"][data-tab=…][data-group=…]`) and
208
+ * on `section.tab[data-tab=…][data-group=…]`, then update
209
+ * `sheet.tabGroups[group]` so subsequent re-renders pick the right
210
+ * initial tab.
211
+ *
212
+ * ApplicationV2's action dispatcher binds `this` to the sheet instance
213
+ * at call time even though the handler is declared `static`.
214
+ */
215
+ static _onTab(_event, target) {
216
+ const sheet = this;
217
+ const group = target.dataset?.group;
218
+ const tab = target.dataset?.tab;
219
+ if (!group || !tab) return;
220
+ sheet.tabGroups[group] = tab;
221
+ const root = sheet.element;
222
+ if (!root) return;
223
+ for (const link of root.querySelectorAll(`[data-action="vttforgeTab"][data-group="${group}"]`)) link.classList.toggle("active", link.dataset.tab === tab);
224
+ for (const section of root.querySelectorAll(`section.tab[data-group="${group}"]`)) section.classList.toggle("active", section.dataset.tab === tab);
225
+ }
226
+ /**
227
+ * Wire each `static DRAG_DROP` entry into a real `DragDrop` instance.
228
+ * Permissions default to `this.isEditable`; callbacks default to
229
+ * `_onDragStart` / `_onDrop`. Subclasses extending `_onRender` MUST call
230
+ * `super._onRender(context, options)` to keep DragDrop wired.
231
+ */
232
+ _onRender(context, options) {
233
+ const superRender = Mixed.prototype._onRender;
234
+ if (typeof superRender === "function") superRender.call(this, context, options);
235
+ const configs = this.constructor.DRAG_DROP;
236
+ if (!configs?.length) return;
237
+ const DragDrop = resolveDragDrop$1();
238
+ if (!DragDrop) return;
239
+ const element = this.element;
240
+ if (!element) return;
241
+ const onDragStart = this._onDragStart;
242
+ const onDrop = this._onDrop;
243
+ for (const cfg of configs) new DragDrop({
244
+ dragSelector: cfg.dragSelector,
245
+ dropSelector: cfg.dropSelector,
246
+ permissions: {
247
+ dragstart: cfg.permissions?.dragstart ?? (() => this.#isEditable()),
248
+ drop: cfg.permissions?.drop ?? (() => this.#isEditable())
249
+ },
250
+ callbacks: {
251
+ ...typeof onDragStart === "function" ? { dragstart: onDragStart.bind(this) } : {},
252
+ ...typeof onDrop === "function" ? { drop: onDrop.bind(this) } : {},
253
+ ...cfg.callbacks ?? {}
254
+ }
255
+ }).bind(element);
256
+ }
257
+ /**
258
+ * Default drag handler — serialises the item identified by
259
+ * `data-item-id` on the drag source element. Override for richer payloads
260
+ * (Actor drags, custom UUIDs).
261
+ */
262
+ _onDragStart(event) {
263
+ const itemId = event.currentTarget?.dataset?.itemId;
264
+ if (!itemId || !event.dataTransfer) return;
265
+ const item = (this.document?.items)?.get(itemId);
266
+ if (!item) return;
267
+ event.dataTransfer.setData("application/json", JSON.stringify({
268
+ type: "Item",
269
+ uuid: item.uuid
270
+ }));
271
+ }
272
+ /**
273
+ * Typed drop sugar. Subclasses override this instead of `_onDropItem`
274
+ * to skip the `fromUuid()` ceremony. Return `undefined` to fall through
275
+ * to Foundry's default `_onDropItem`; return anything else to take
276
+ * ownership of the drop.
277
+ */
278
+ async onDropItem(_item, _event) {}
279
+ async onDropActor(_actor, _event) {}
280
+ async onDropFolder(_folder, _event) {}
281
+ async onDropActiveEffect(_effect, _event) {}
282
+ async _onDropItem(event, data) {
283
+ return this.#dispatchDrop("_onDropItem", "onDropItem", event, data);
284
+ }
285
+ async _onDropActor(event, data) {
286
+ return this.#dispatchDrop("_onDropActor", "onDropActor", event, data);
287
+ }
288
+ async _onDropFolder(event, data) {
289
+ return this.#dispatchDrop("_onDropFolder", "onDropFolder", event, data);
290
+ }
291
+ async _onDropActiveEffect(event, data) {
292
+ return this.#dispatchDrop("_onDropActiveEffect", "onDropActiveEffect", event, data);
293
+ }
294
+ async #dispatchDrop(superKey, sugarKey, event, data) {
295
+ const uuid = data?.uuid;
296
+ if (uuid) {
297
+ const doc = await resolveFromUuid(uuid);
298
+ if (doc) {
299
+ const result = await this[sugarKey](doc, event);
300
+ if (result !== void 0) return result;
301
+ }
302
+ }
303
+ const superFn = Mixed.prototype[superKey];
304
+ if (typeof superFn === "function") return superFn.call(this, event, data);
305
+ }
306
+ #isEditable() {
307
+ return Boolean(this.isEditable);
308
+ }
309
+ }
310
+ return VttforgeBaseActorSheet;
311
+ }
312
+ //#endregion
313
+ //#region src/base-item-sheet.ts
314
+ function resolveBases() {
315
+ const foundry = globalThis.foundry;
316
+ const Base = foundry?.applications?.sheets?.ItemSheetV2;
317
+ const mixin = foundry?.applications?.api?.HandlebarsApplicationMixin;
318
+ if (typeof Base !== "function" || typeof mixin !== "function") throw new VttfError("VTTF-0002", "foundry.applications.sheets.ItemSheetV2 and/or foundry.applications.api.HandlebarsApplicationMixin are not available. Define your BaseItemSheet subclasses inside the Foundry runtime (or stub the global in tests).");
319
+ return {
320
+ Base,
321
+ mixin
322
+ };
323
+ }
324
+ function resolveDragDrop() {
325
+ const ctor = globalThis.foundry?.applications?.ux?.DragDrop;
326
+ return typeof ctor === "function" ? ctor : void 0;
327
+ }
328
+ /**
329
+ * Build the `BaseItemSheet` for the current Foundry runtime.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * class WeaponSheet extends BaseItemSheet() {
334
+ * static DEFAULT_OPTIONS = foundry.utils.mergeObject(
335
+ * super.DEFAULT_OPTIONS,
336
+ * { classes: ['my-system'], position: { width: 540 } },
337
+ * );
338
+ * static PARTS = { ... };
339
+ * static TABS = {
340
+ * primary: {
341
+ * tabs: [
342
+ * { id: 'description', group: 'primary', label: 'Description' },
343
+ * { id: 'details', group: 'primary', label: 'Details' },
344
+ * ],
345
+ * initial: 'description',
346
+ * },
347
+ * };
348
+ * }
349
+ * ```
350
+ */
351
+ function BaseItemSheet() {
352
+ const { Base, mixin } = resolveBases();
353
+ const Mixed = mixin(Base);
354
+ class VttforgeBaseItemSheet extends Mixed {
355
+ static DEFAULT_OPTIONS = {
356
+ classes: [VTTFORGE_SHEET_CLASS],
357
+ window: { resizable: true },
358
+ position: {
359
+ width: 520,
360
+ height: 480
361
+ },
362
+ tag: "form",
363
+ form: {
364
+ submitOnChange: true,
365
+ closeOnSubmit: false
366
+ },
367
+ actions: { vttforgeTab: VttforgeBaseItemSheet._onTab }
368
+ };
369
+ static DRAG_DROP = [];
370
+ /**
371
+ * Multi-group sheets get nested `context.tabs.<group>.<tabId>` because
372
+ * ApplicationV2 returns `{}` for them by default; single-group sheets
373
+ * use ApplicationV2's flat `context.tabs.<tabId>` shape untouched. See
374
+ * BaseActorSheet for the long version.
375
+ */
376
+ async _prepareContext(options) {
377
+ const superPrepare = Mixed.prototype._prepareContext;
378
+ const context = typeof superPrepare === "function" ? await superPrepare.call(this, options) : {};
379
+ const tabsConfig = this.constructor.TABS;
380
+ if (tabsConfig && typeof tabsConfig === "object") {
381
+ const groups = Object.keys(tabsConfig);
382
+ if (groups.length > 1) {
383
+ const prepareTabs = this._prepareTabs;
384
+ const tabs = {};
385
+ for (const group of groups) tabs[group] = typeof prepareTabs === "function" ? prepareTabs.call(this, group) : {};
386
+ context.tabs = tabs;
387
+ }
388
+ }
389
+ return context;
390
+ }
391
+ static _onTab(_event, target) {
392
+ const sheet = this;
393
+ const group = target.dataset?.group;
394
+ const tab = target.dataset?.tab;
395
+ if (!group || !tab) return;
396
+ sheet.tabGroups[group] = tab;
397
+ const root = sheet.element;
398
+ if (!root) return;
399
+ for (const link of root.querySelectorAll(`[data-action="vttforgeTab"][data-group="${group}"]`)) link.classList.toggle("active", link.dataset.tab === tab);
400
+ for (const section of root.querySelectorAll(`section.tab[data-group="${group}"]`)) section.classList.toggle("active", section.dataset.tab === tab);
401
+ }
402
+ _onRender(context, options) {
403
+ const superRender = Mixed.prototype._onRender;
404
+ if (typeof superRender === "function") superRender.call(this, context, options);
405
+ const configs = this.constructor.DRAG_DROP;
406
+ if (!configs?.length) return;
407
+ const DragDrop = resolveDragDrop();
408
+ if (!DragDrop) return;
409
+ const element = this.element;
410
+ if (!element) return;
411
+ const onDragStart = this._onDragStart;
412
+ const onDrop = this._onDrop;
413
+ for (const cfg of configs) new DragDrop({
414
+ dragSelector: cfg.dragSelector,
415
+ dropSelector: cfg.dropSelector,
416
+ permissions: {
417
+ dragstart: cfg.permissions?.dragstart ?? (() => this.#isEditable()),
418
+ drop: cfg.permissions?.drop ?? (() => this.#isEditable())
419
+ },
420
+ callbacks: {
421
+ ...typeof onDragStart === "function" ? { dragstart: onDragStart.bind(this) } : {},
422
+ ...typeof onDrop === "function" ? { drop: onDrop.bind(this) } : {},
423
+ ...cfg.callbacks ?? {}
424
+ }
425
+ }).bind(element);
426
+ }
427
+ #isEditable() {
428
+ return Boolean(this.isEditable);
429
+ }
430
+ }
431
+ return VttforgeBaseItemSheet;
432
+ }
433
+ //#endregion
434
+ //#region src/base-type-data-model.ts
435
+ function resolveTypeDataModelClass() {
436
+ const cls = globalThis.foundry?.abstract?.TypeDataModel;
437
+ if (typeof cls !== "function") throw new VttfError("VTTF-0002", "foundry.abstract.TypeDataModel is not available. Define your BaseTypeDataModel subclasses inside the Foundry runtime (or stub the global in tests).");
438
+ return cls;
439
+ }
440
+ function BaseTypeDataModel(defineSchema) {
441
+ const Base = resolveTypeDataModelClass();
442
+ class VttforgeBaseTypeDataModel extends Base {
443
+ /**
444
+ * Default no-op so subclasses can omit it when they have no value-level
445
+ * migrations. Always end with `super.migrateData(data)` if you override.
446
+ */
447
+ static migrateData(data) {
448
+ const superMigrateData = Base.migrateData;
449
+ if (typeof superMigrateData === "function") return superMigrateData.call(VttforgeBaseTypeDataModel, data);
450
+ return data;
451
+ }
452
+ /**
453
+ * No-op stub. Override per type to initialize fields whose values Active
454
+ * Effects need to consume — base max HP, base AC, etc. Foundry calls this
455
+ * BEFORE applying Active Effects, so anything you set here is the input
456
+ * that AE changes (`ADD`, `MULTIPLY`, `OVERRIDE`, …) operate on.
457
+ *
458
+ * Use `prepareDerivedData()` instead for values that depend on the
459
+ * AE-mutated state (modifiers, percentages, totals).
460
+ *
461
+ * Never write to the database here — purely in-memory.
462
+ */
463
+ prepareBaseData() {}
464
+ /**
465
+ * No-op stub. Override per type to compute derived values from the
466
+ * AE-mutated state (modifiers, percentages, totals). Runs AFTER Active
467
+ * Effects apply; use `prepareBaseData()` for values that AEs need to read.
468
+ *
469
+ * Never write to the database here — purely in-memory.
470
+ */
471
+ prepareDerivedData() {}
472
+ }
473
+ if (defineSchema !== void 0) Object.defineProperty(VttforgeBaseTypeDataModel, "defineSchema", {
474
+ value: defineSchema,
475
+ writable: true,
476
+ configurable: true
477
+ });
478
+ return VttforgeBaseTypeDataModel;
479
+ }
480
+ //#endregion
481
+ //#region src/data/fields.ts
482
+ /**
483
+ * `fields()` — typed bag of Foundry v13 data-field constructors.
484
+ *
485
+ * Foundry idiom inside `defineSchema()` is `const f = foundry.data.fields`. We
486
+ * mirror that, but routed through a factory so the import succeeds in Node
487
+ * (tests, IDE typecheck) where the global is absent. The factory resolves
488
+ * `globalThis.foundry.data.fields` lazily — same pattern as
489
+ * `BaseTypeDataModel()` (see `base-type-data-model.ts:25`) and
490
+ * `BaseActorSheet()` (see `base-actor-sheet.ts:40`).
491
+ *
492
+ * v0.1 covers eight fields (PRD §7): NumberField, StringField, BooleanField,
493
+ * HTMLField, ArrayField, SchemaField, ColorField, FilePathField. The instance
494
+ * interfaces carry a phantom `[BRAND]` tag and an `options` capture so the
495
+ * conditional types in `./infer-schema.ts` can extract the runtime semantics
496
+ * (e.g. `nullable: true`).
497
+ *
498
+ * `EmbeddedDataField`, `EmbeddedDocumentField`, `TypedSchemaField`, and the
499
+ * full required×initial nullability matrix ship with `@vttforge/types` v1.0.
500
+ */
501
+ /**
502
+ * Resolve `globalThis.foundry.data.fields` and return it typed as `FieldsApi`.
503
+ *
504
+ * Call this inside `defineSchema()` (or any code that runs after Foundry's
505
+ * `init` hook). Calling at module scope will throw when imported from Node
506
+ * tests — the global only exists inside the Foundry runtime.
507
+ *
508
+ * @throws `VttfError` with code `VTTF-0002` when `foundry.data.fields` is
509
+ * missing.
510
+ */
511
+ function fields() {
512
+ const f = globalThis.foundry?.data?.fields;
513
+ if (f === void 0 || f === null) throw new VttfError("VTTF-0002", "foundry.data.fields is not available. Call fields() inside the Foundry runtime (or stub the global in tests).");
514
+ return f;
515
+ }
516
+ //#endregion
517
+ //#region src/errors/manifest.ts
518
+ /**
519
+ * Typed runtime view over the VTTF-NNNN registry.
520
+ *
521
+ * Same data as `listErrorEntries()` — the manifest wraps it in a versioned
522
+ * envelope so external tooling (the v0.3 docs site, IDE extensions, lint
523
+ * rules) has a stable shape to consume. The matching JSON projection is
524
+ * emitted to `dist/errors-manifest.json` at build time by
525
+ * `packages/core/scripts/codegen-errors.mjs`.
526
+ */
527
+ const ERROR_MANIFEST_VERSION = 1;
528
+ /**
529
+ * Snapshot the current registry as a manifest object. Recomputed on every
530
+ * call — cheap (the registry is a frozen literal). For the JSON projection
531
+ * shipped with the package, see `dist/errors-manifest.json`.
532
+ */
533
+ function getErrorManifest() {
534
+ return {
535
+ version: 1,
536
+ package: "@vttforge/core",
537
+ entries: listErrorEntries()
538
+ };
539
+ }
540
+ //#endregion
541
+ //#region src/migrations/runner.ts
542
+ /**
543
+ * `createMigrationRunner` — declarative schema migrations for Foundry systems.
544
+ *
545
+ * Replaces the copy-pasted "schemaVersion setting + Hooks.once('ready') +
546
+ * isNewerVersion compare + sequential await" pattern that every system
547
+ * eventually grows on its own. The runner owns no hooks — call
548
+ * `register()` from your `init` hook and `run()` from your `ready` hook
549
+ * (gated by `game.user.isGM`).
550
+ *
551
+ * Versions are semver strings, compared with `foundry.utils.isNewerVersion`.
552
+ * The data lives in a per-system world setting and lines up cleanly with
553
+ * `system.json`'s `flags.<systemId>.needsMigrationVersion` /
554
+ * `compatibleMigrationVersion`.
555
+ *
556
+ * Failures advance `schemaVersion` only past migrations that *completed* — a
557
+ * mid-sequence throw leaves the world at the last successful version so the
558
+ * retry on the next world load picks up exactly where it failed.
559
+ */
560
+ const DEFAULT_SETTING_KEY = "schemaVersion";
561
+ const INITIAL_VERSION = "0.0.0";
562
+ function resolveIsNewerVersion() {
563
+ const fn = globalThis.foundry?.utils?.isNewerVersion;
564
+ if (typeof fn === "function") return fn;
565
+ return naiveIsNewerVersion;
566
+ }
567
+ function naiveIsNewerVersion(next, current) {
568
+ const parse = (v) => v.split(".").map((part) => {
569
+ const n = Number.parseInt(part, 10);
570
+ return Number.isNaN(n) ? 0 : n;
571
+ });
572
+ const a = parse(next);
573
+ const b = parse(current);
574
+ const len = Math.max(a.length, b.length);
575
+ for (let i = 0; i < len; i++) {
576
+ const ai = a[i] ?? 0;
577
+ const bi = b[i] ?? 0;
578
+ if (ai > bi) return true;
579
+ if (ai < bi) return false;
580
+ }
581
+ return false;
582
+ }
583
+ function resolveSettings() {
584
+ const settings = globalThis.game?.settings;
585
+ if (settings === void 0 || typeof settings.register !== "function" || typeof settings.get !== "function" || typeof settings.set !== "function") throw new VttfError("VTTF-0002", "globalThis.game.settings is not available — call createMigrationRunner().register() inside the Foundry runtime (or pass an explicit settings adapter in MigrationRunnerOptions).");
586
+ return settings;
587
+ }
588
+ function resolveLogger() {
589
+ const notifications = globalThis.ui?.notifications;
590
+ return {
591
+ info(message) {
592
+ console.info(message);
593
+ notifications?.info?.(message);
594
+ },
595
+ warn(message) {
596
+ console.warn(message);
597
+ notifications?.warn?.(message);
598
+ },
599
+ error(message) {
600
+ console.error(message);
601
+ notifications?.error?.(message);
602
+ }
603
+ };
604
+ }
605
+ function lastVersion(migrations) {
606
+ return migrations.at(-1)?.version ?? INITIAL_VERSION;
607
+ }
608
+ function assertAscending(migrations, isNewer) {
609
+ for (let i = 1; i < migrations.length; i++) {
610
+ const prevMig = migrations[i - 1];
611
+ const nextMig = migrations[i];
612
+ if (prevMig === void 0 || nextMig === void 0) continue;
613
+ if (!isNewer(nextMig.version, prevMig.version)) throw new VttfError("VTTF-0004", `Migration list out of order: ${nextMig.version} must be newer than ${prevMig.version}.`);
614
+ }
615
+ }
616
+ /**
617
+ * Build a migration runner for a system. See module header for the failure
618
+ * semantics; see `Migration` JSDoc for the per-entry shape.
619
+ *
620
+ * @example
621
+ * ```ts
622
+ * const migrations = createMigrationRunner({
623
+ * systemId: 'my-system',
624
+ * migrations: [
625
+ * { version: '1.0.0', description: 'Rename bio → biography', fn: migrateV1 },
626
+ * { version: '2.0.0', description: 'Add hp.temp', fn: migrateV2 },
627
+ * ],
628
+ * compatibleVersion: '0.9.0',
629
+ * });
630
+ *
631
+ * registerSystem({
632
+ * id: 'my-system',
633
+ * onAfterInit: () => migrations.register(),
634
+ * onReady: async () => {
635
+ * if (!game.user.isGM) return;
636
+ * await migrations.run();
637
+ * },
638
+ * });
639
+ * ```
640
+ */
641
+ function createMigrationRunner(options) {
642
+ const settingKey = options.settingKey ?? DEFAULT_SETTING_KEY;
643
+ const target = lastVersion(options.migrations);
644
+ const settingsOverride = options.settings;
645
+ const loggerOverride = options.logger;
646
+ const isNewerOverride = options.isNewerVersion;
647
+ return {
648
+ targetVersion: target,
649
+ register() {
650
+ (settingsOverride ?? resolveSettings()).register(options.systemId, settingKey, {
651
+ name: "Schema Version",
652
+ hint: "Internal schema version for VTTForge data migration tracking. Do not edit by hand.",
653
+ scope: "world",
654
+ config: false,
655
+ type: String,
656
+ default: INITIAL_VERSION
657
+ });
658
+ },
659
+ async run() {
660
+ if (options.migrations.length === 0) return [];
661
+ const settings = settingsOverride ?? resolveSettings();
662
+ const logger = loggerOverride ?? resolveLogger();
663
+ const isNewer = isNewerOverride ?? resolveIsNewerVersion();
664
+ assertAscending(options.migrations, isNewer);
665
+ const current = settings.get(options.systemId, settingKey) ?? INITIAL_VERSION;
666
+ if (options.compatibleVersion !== void 0) {
667
+ if (isNewer(options.compatibleVersion, current)) throw new VttfError("VTTF-0005", `World schemaVersion ${current} is older than ${options.systemId}'s compatibleVersion ${options.compatibleVersion}. Upgrade through an intermediate release first.`);
668
+ }
669
+ const pending = options.migrations.filter((m) => isNewer(m.version, current));
670
+ if (pending.length === 0) return [];
671
+ const ran = [];
672
+ let lastApplied = current;
673
+ logger.warn(`${options.systemId} | Running ${pending.length} pending migration(s) from ${current} to ${target}.`);
674
+ for (const migration of pending) {
675
+ const label = migration.description ? `${migration.version} — ${migration.description}` : migration.version;
676
+ logger.info(`${options.systemId} | Migrating to ${label}`);
677
+ try {
678
+ await migration.fn();
679
+ } catch (cause) {
680
+ if (isNewer(lastApplied, current)) await settings.set(options.systemId, settingKey, lastApplied);
681
+ throw new VttfError("VTTF-0004", `Migration to ${label} failed for system "${options.systemId}". schemaVersion left at ${lastApplied}.`, { cause });
682
+ }
683
+ await settings.set(options.systemId, settingKey, migration.version);
684
+ lastApplied = migration.version;
685
+ ran.push(migration.version);
686
+ }
687
+ logger.info(`${options.systemId} | Migration complete. schemaVersion = ${target}.`);
688
+ return ran;
689
+ }
690
+ };
691
+ }
692
+ //#endregion
693
+ //#region src/register-module.ts
694
+ /**
695
+ * registerModule — the module counterpart to `registerSystem`.
696
+ *
697
+ * A module is a guest in someone else's world, and Foundry enforces that. The
698
+ * two differences that matter:
699
+ *
700
+ * - **Sub-type keys are namespaced.** A system registers `character`; a module
701
+ * registering the same thing must register `<module-id>.character`, and the
702
+ * manifest must declare it under `documentTypes`. Forget the prefix and the
703
+ * type silently never appears. This function adds it for you.
704
+ * - **A module never owns the globals.** Document classes, the initiative
705
+ * formula and the status-effect array belong to the system. So there is no
706
+ * option here to replace them — `statusEffects` only appends, which is what
707
+ * a module is allowed to do.
708
+ */
709
+ const registered$1 = /* @__PURE__ */ new Set();
710
+ /**
711
+ * The key Foundry files a module's document sub-type under.
712
+ *
713
+ * Use it wherever you name the type outside `registerModule` — registering the
714
+ * sheet, checking `actor.type`, writing `documentTypes` in the manifest. The
715
+ * prefix is easy to get wrong by hand and fails silently when you do.
716
+ *
717
+ * @example
718
+ * ```ts
719
+ * moduleSubType('pdf-character-sheet', 'pdf'); // 'pdf-character-sheet.pdf'
720
+ * ```
721
+ */
722
+ function moduleSubType(moduleId, type) {
723
+ return `${moduleId}.${type}`;
724
+ }
725
+ function vttfError$1(code, message) {
726
+ return new VttfError(code, message);
727
+ }
728
+ function readHooks$1() {
729
+ const hooks = globalThis.Hooks;
730
+ if (hooks === void 0 || typeof hooks.once !== "function") throw vttfError$1("VTTF-0002", "globalThis.Hooks is not available — call registerModule() inside a Foundry runtime or stub Hooks in tests");
731
+ return hooks;
732
+ }
733
+ function readConfig$1() {
734
+ const config = globalThis.CONFIG;
735
+ if (config === void 0) throw vttfError$1("VTTF-0002", "globalThis.CONFIG is not available — call registerModule() inside a Foundry runtime or stub CONFIG in tests");
736
+ return config;
737
+ }
738
+ function assignSubTypes(target, moduleId, models) {
739
+ for (const [type, model] of Object.entries(models)) target[moduleSubType(moduleId, type)] = model;
740
+ }
741
+ /**
742
+ * Register a Foundry module with VTTForge.
743
+ *
744
+ * Calling twice with the same `id` throws VTTF-0001 — almost always a
745
+ * hot-reload artefact or a duplicate import. The CONFIG mutations are deferred
746
+ * until Foundry's `init` hook fires.
747
+ */
748
+ function registerModule(config) {
749
+ if (registered$1.has(config.id)) throw vttfError$1("VTTF-0001", `Module "${config.id}" was already registered`);
750
+ registered$1.add(config.id);
751
+ const hooks = readHooks$1();
752
+ hooks.once("init", () => {
753
+ applyInit$1(config);
754
+ });
755
+ if (config.onReady !== void 0) hooks.once("ready", () => {
756
+ config.onReady?.();
757
+ });
758
+ return config;
759
+ }
760
+ function applyInit$1(config) {
761
+ config.onBeforeInit?.();
762
+ const CONFIG = readConfig$1();
763
+ if (config.actorDataModels !== void 0) assignSubTypes(CONFIG.Actor.dataModels, config.id, config.actorDataModels);
764
+ if (config.itemDataModels !== void 0) assignSubTypes(CONFIG.Item.dataModels, config.id, config.itemDataModels);
765
+ if (config.statusEffects !== void 0 && config.statusEffects.length > 0) {
766
+ CONFIG.statusEffects ??= [];
767
+ CONFIG.statusEffects.push(...config.statusEffects);
768
+ }
769
+ config.onAfterInit?.();
770
+ }
771
+ //#endregion
772
+ //#region src/register-system.ts
773
+ /**
774
+ * registerSystem — one call that replaces the boilerplate `Hooks.once("init", ...)`
775
+ * block in every Foundry system.
776
+ *
777
+ * Conforms to the canonical Foundry init lifecycle:
778
+ *
779
+ * init → CONFIG mutations (dataModels, documentClass, statusEffects)
780
+ * i18nInit → translate CONFIG labels
781
+ * setup → enrichers, packs
782
+ * ready → migrations (GM-only — consumer guards inside onReady)
783
+ *
784
+ * v0.1 scope: `init` + `ready`. `setup` / `i18nInit` callbacks remain v0.1.1.
785
+ *
786
+ * Per PRD §11 open question #1, we wrap the hook ourselves ("explicit hook for
787
+ * now"); callers don't need to write `Hooks.once("init", ...)` themselves.
788
+ */
789
+ const registered = /* @__PURE__ */ new Set();
790
+ function readHooks() {
791
+ const hooks = globalThis.Hooks;
792
+ if (hooks === void 0 || typeof hooks.once !== "function") throw vttfError("VTTF-0002", "globalThis.Hooks is not available — call registerSystem() inside a Foundry runtime or stub Hooks in tests");
793
+ return hooks;
794
+ }
795
+ function readConfig() {
796
+ const config = globalThis.CONFIG;
797
+ if (config === void 0) throw vttfError("VTTF-0002", "globalThis.CONFIG is not available — call registerSystem() inside a Foundry runtime or stub CONFIG in tests");
798
+ return config;
799
+ }
800
+ function vttfError(code, message) {
801
+ return new VttfError(code, message);
802
+ }
803
+ /**
804
+ * Register a Foundry system with VTTForge. Idempotency: the same `id` calling
805
+ * twice throws VTTF-0001 — almost always a hot-reload or duplicate import bug.
806
+ *
807
+ * Returns the registration object so consumers can inspect what was applied
808
+ * (useful in tests). The actual CONFIG mutations are deferred until Foundry's
809
+ * `init` hook fires.
810
+ */
811
+ function registerSystem(config) {
812
+ if (registered.has(config.id)) throw vttfError("VTTF-0001", `System "${config.id}" was already registered`);
813
+ registered.add(config.id);
814
+ const hooks = readHooks();
815
+ hooks.once("init", () => {
816
+ applyInit(config);
817
+ });
818
+ if (config.onReady !== void 0) hooks.once("ready", () => {
819
+ config.onReady?.();
820
+ });
821
+ return config;
822
+ }
823
+ function applyInit(config) {
824
+ config.onBeforeInit?.();
825
+ const CONFIG = readConfig();
826
+ if (config.actorDataModels !== void 0) Object.assign(CONFIG.Actor.dataModels, config.actorDataModels);
827
+ if (config.itemDataModels !== void 0) Object.assign(CONFIG.Item.dataModels, config.itemDataModels);
828
+ if (config.actorDocumentClass !== void 0) CONFIG.Actor.documentClass = config.actorDocumentClass;
829
+ if (config.itemDocumentClass !== void 0) CONFIG.Item.documentClass = config.itemDocumentClass;
830
+ if (config.combat?.initiative !== void 0) CONFIG.Combat.initiative = config.combat.initiative;
831
+ const legacyTransferral = config.activeEffect?.legacyTransferral ?? false;
832
+ CONFIG.ActiveEffect.legacyTransferral = legacyTransferral;
833
+ if (config.statusEffects !== void 0) CONFIG.statusEffects = [...config.statusEffects];
834
+ config.onAfterInit?.();
835
+ }
836
+ //#endregion
837
+ //#region src/system-config.ts
838
+ /**
839
+ * SystemConfig — typed wrapper around `game.settings.register/get/set`.
840
+ *
841
+ * Eliminates the boilerplate of repeating the system id in every call:
842
+ *
843
+ * // before
844
+ * game.settings.register("ordemparanormal", "homebrewRules", { ... });
845
+ * game.settings.get("ordemparanormal", "homebrewRules");
846
+ *
847
+ * // after
848
+ * const cfg = new SystemConfig("ordemparanormal");
849
+ * cfg.register("homebrewRules", { ... });
850
+ * cfg.get<boolean>("homebrewRules");
851
+ *
852
+ * Also keeps a local manifest of registered keys so attempts to read an
853
+ * unregistered key fail with VTTF-0003 instead of returning undefined.
854
+ *
855
+ * Registration must happen during the `init` hook; reads can happen any
856
+ * time after.
857
+ */
858
+ function readGame() {
859
+ const candidate = globalThis.game;
860
+ if (candidate === void 0 || candidate.settings === void 0) throw new VttfError("VTTF-0002", "game.settings is not available — call SystemConfig methods inside or after the Foundry \"init\" hook");
861
+ return candidate;
862
+ }
863
+ var SystemConfig = class {
864
+ systemId;
865
+ #registered = /* @__PURE__ */ new Set();
866
+ constructor(systemId) {
867
+ this.systemId = systemId;
868
+ }
869
+ register(key, config) {
870
+ readGame().settings.register(this.systemId, key, config);
871
+ this.#registered.add(key);
872
+ }
873
+ get(key) {
874
+ if (!this.#registered.has(key)) throw new VttfError("VTTF-0003", `SystemConfig.get("${key}") was called before register("${key}")`);
875
+ return readGame().settings.get(this.systemId, key);
876
+ }
877
+ async set(key, value) {
878
+ if (!this.#registered.has(key)) throw new VttfError("VTTF-0003", `SystemConfig.set("${key}") was called before register("${key}")`);
879
+ return readGame().settings.set(this.systemId, key, value);
880
+ }
881
+ isRegistered(key) {
882
+ return this.#registered.has(key);
883
+ }
884
+ };
885
+ //#endregion
886
+ //#region src/index.ts
887
+ /**
888
+ * @vttforge/core — runtime utilities for FoundryVTT v13+ systems and modules.
889
+ *
890
+ * v0.1 surface:
891
+ *
892
+ * - registerSystem() — one-call init, replaces Hooks.once("init")
893
+ * - registerModule() — the same for modules, with namespaced sub-types
894
+ * - SystemConfig — typed wrapper around game.settings
895
+ * - BaseTypeDataModel() — TypeDataModel with safe migrateData default
896
+ * - BaseActorSheet() — ActorSheetV2 + HandlebarsApplicationMixin
897
+ * - BaseItemSheet() — ItemSheetV2 + HandlebarsApplicationMixin
898
+ * - fields() — typed bag of foundry.data.fields constructors
899
+ * - InferSchema<T> — derive `system` shape from defineSchema()
900
+ * - createMigrationRunner() — declarative schema migrations (register + run)
901
+ * - VttfError + error registry — VTTF-NNNN codes with docs URLs
902
+ *
903
+ * Foundry classes are resolved from `globalThis.foundry` lazily so the package
904
+ * imports cleanly in Node/tests; concrete Foundry typing arrives with
905
+ * `@vttforge/types` in v1.0.
906
+ */
907
+ const VTTFORGE_CORE_VERSION = "0.2.0";
908
+ //#endregion
909
+ export { BaseActorSheet, BaseItemSheet, BaseTypeDataModel, ERROR_MANIFEST_VERSION, SystemConfig, VTTFORGE_CORE_VERSION, VTTFORGE_SHEET_CLASS, VttfError, createMigrationRunner, docsUrlFor, fields, getErrorEntry, getErrorManifest, listErrorEntries, moduleSubType, registerModule, registerSystem };
910
+
911
+ //# sourceMappingURL=index.mjs.map