From debcf9d82979bee27aa59c129273761d59aa54c8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 19 Apr 2026 14:20:28 -0600 Subject: [PATCH 01/50] Add the attribute item type to the manifest --- system.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system.json b/system.json index 6a9bdef..ea94abd 100644 --- a/system.json +++ b/system.json @@ -45,6 +45,10 @@ "description" ], "filePathFields": {} + }, + "attribute": { + "htmlFields": [], + "filePathFields": {} } } }, -- 2.49.1 From 8f8da244c0b6346a2d7c669ae966b9cf2cb36f25 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 19 Apr 2026 14:53:05 -0600 Subject: [PATCH 02/50] Add the Attribute item data model --- module/data/Item/attribute.mjs | 35 ++++++++++++++++++++++++++++++++++ module/hooks/init.mjs | 3 +++ 2 files changed, 38 insertions(+) create mode 100644 module/data/Item/attribute.mjs diff --git a/module/data/Item/attribute.mjs b/module/data/Item/attribute.mjs new file mode 100644 index 0000000..e1426a7 --- /dev/null +++ b/module/data/Item/attribute.mjs @@ -0,0 +1,35 @@ +export class AttributeItemData extends foundry.abstract.TypeDataModel { + static defineSchema() { + const fields = foundry.data.fields; + return { + group: new fields.StringField({ + blank: false, + trim: true, + nullable: true, + initial: null, + }), + key: new fields.StringField({ + blank: false, + trim: true, + nullable: true, + initial: null, + }), + aboveTheFold: new fields.BooleanField({ + initial: false, + }), + + /* The attributes current value */ + value: new fields.NumberField({ + integer: true, + }), + /* The minimum accepted value */ + min: new fields.NumberField({ + integer: true, + }), + /* The maximum accepted value */ + max: new fields.NumberField({ + integer: true, + }), + }; + }; +}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index a6f623e..995b6fb 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -5,6 +5,7 @@ import { PlayerSheet } from "../apps/PlayerSheet.mjs"; import { SingleModePlayerSheet } from "../apps/SingleModePlayerSheet.mjs"; // Data Models +import { AttributeItemData } from "../data/Item/attribute.mjs"; import { GenericItemData } from "../data/Item/generic.mjs"; import { PlayerData } from "../data/Actor/player.mjs"; @@ -35,6 +36,7 @@ Hooks.on(`init`, () => { // #region Data Models CONFIG.Actor.dataModels.player = PlayerData; CONFIG.Item.dataModels.generic = GenericItemData; + CONFIG.Item.dataModels.attribute = AttributeItemData; // #endregion Data Models // #region Sheets @@ -61,6 +63,7 @@ Hooks.on(`init`, () => { __ID__, GenericItemSheet, { + types: [`generic`], makeDefault: true, label: `taf.sheet-names.GenericItemSheet`, }, -- 2.49.1 From 14f76e0a27a0c15795ccb72b2598ac5f634f6edc Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 19 Apr 2026 23:41:49 -0600 Subject: [PATCH 03/50] Begin working on the migration script upon world load for Actors --- module/documents/Actor.mjs | 8 ++- module/hooks/ready.mjs | 4 ++ module/migrations/checkMigrations.mjs | 24 ++++++++ module/migrations/utils.mjs | 79 +++++++++++++++++++++++++++ module/migrations/v3.0.0.mjs | 72 ++++++++++++++++++++++++ module/settings/world.mjs | 6 ++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 module/migrations/checkMigrations.mjs create mode 100644 module/migrations/utils.mjs create mode 100644 module/migrations/v3.0.0.mjs diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index acbe5e0..9969f7a 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -1,7 +1,7 @@ import { __ID__ } from "../consts.mjs"; const { Actor } = foundry.documents; -const { hasProperty } = foundry.utils; +const { hasProperty, setProperty } = foundry.utils; export class TAFActor extends Actor { @@ -33,6 +33,12 @@ export class TAFActor extends Actor { super._onEmbeddedDocumentChange(...args); this.#sortedTypes = null; }; + + static migrateData(data, ...args) { + if (Object.keys(data.system?.attr ?? {}).length > 0) { + setProperty(data, `flags.${__ID__}.convertAttributesIntoItems`, true); + }; + }; // #endregion Lifecycle // #region Token Attributes diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs index 94c7249..2a8bf39 100644 --- a/module/hooks/ready.mjs +++ b/module/hooks/ready.mjs @@ -1,6 +1,10 @@ +import { checkMigrations } from "../migrations/checkMigrations.mjs"; + Hooks.on(`ready`, () => { // Remove with issue: Foundry/taf#52 if (game.release.generation < 14 && globalThis._loc == null) { globalThis._loc = game.i18n.format.bind(game.i18n); }; + + checkMigrations(); }); diff --git a/module/migrations/checkMigrations.mjs b/module/migrations/checkMigrations.mjs new file mode 100644 index 0000000..776b25c --- /dev/null +++ b/module/migrations/checkMigrations.mjs @@ -0,0 +1,24 @@ +import { __ID__ } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; +import { migrateTo3_0_0 } from "./v3.0.0.mjs"; + +const { isNewerVersion } = foundry.utils; + +export async function checkMigrations() { + if (!game.user.isActiveGM) { + Logger.debug(`User not active GM, skipping data migrations`); + return; + }; + + const migrationVersion = game.settings.get(__ID__, `migrationVersion`); + let updateVersion = !migrationVersion; + + if (isNewerVersion("3.0.0", migrationVersion)) { + await migrateTo3_0_0(); + updateVersion = true; + }; + + if (updateVersion) { + game.settings.set(__ID__, `migrationVersion`, game.system.version); + }; +}; diff --git a/module/migrations/utils.mjs b/module/migrations/utils.mjs new file mode 100644 index 0000000..807e69a --- /dev/null +++ b/module/migrations/utils.mjs @@ -0,0 +1,79 @@ +import { __ID__ } from "../consts.mjs"; + +/** + * Migrate the documents within a collection based on what + * + * This function was originally reproduced from [Draw Steel's codebase](https://github.com/MetaMorphic-Digital/draw-steel/blob/82a0a050da7c0d6d28c0cd283cf3b6915f47ee2a/src/module/data/migrations.mjs#L206-L226), + * with modifications to it to work better without + * + * @param collection The Collection of documents to update. + * @param flag The flag name to reference for if the document should be migrated. + * @param convertor The function that takes the document and performs the. + * transformations to get the required update data. + * @param options Options to configure how the method behaves. + * @param options.pack The compendium pack to update. + * @param options.parent Parent of the collection for embedded collections. + * @param options.update Whether or not this method should perform the update, or pass back the array of DB operations. + * @returns An array of batch operations to perform. + */ +export async function migrateCollection( + collection, + flag, + convertor, + options = {} +) { + const toMigrate = collection + .filter(doc => { + console.log(`toMigrate.filter doc`, doc); + return doc.getFlag(__ID__, flag) + }) + .map(doc => { + const update = convertor(doc) ?? {}; + update[`_id`] = doc._id; + + // v13/v14+ compatibility shim + if (game.release.generation > 13) { + update[`flags.${__ID__}.${flag}`] = _del; + } else { + update[`flags.${__ID__}.-=${flag}`] = null; + }; + + return update; + }) + .filter(data => !!data); + // update in increments of 100 + // TODO: optionally return an array of DB operations for modifyBatch + const batches = Math.ceil(toMigrate.length / 100); + for (let i = 0; i < batches; i++) { + const updateData = toMigrate.slice(i * 100, (i + 1) * 100); + await collection.documentClass.updateDocuments( + updateData, + { + pack: options.pack, + parent: options.parent, + diff: false, + }, + ); + }; +}; + +/** + * Determine whether a compendium pack should be migrated during `migrateWorld`. + * + * This function was reproduced from [Draw Steel's codebase](https://github.com/MetaMorphic-Digital/draw-steel/blob/82a0a050da7c0d6d28c0cd283cf3b6915f47ee2a/src/module/data/migrations.mjs#L287-L302) + * + * @param pack The CompendiumPack document + * @returns {boolean} Whether or not the pack should be migrated + */ +export function shouldMigrateCompendium(pack) { + // We only care about actor and item migrations + if (!["Actor", "Item"].includes(pack.documentName)) return false; + + // World compendiums should all be migrated, system ones should never by migrated + if (pack.metadata.packageType === "world") return true; + if (pack.metadata.packageType === "system") return false; + + // Module compendiums should only be migrated if they don't have a download or manifest URL + const module = game.modules.get(pack.metadata.packageName); + return !module.download && !module.manifest; +} diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs new file mode 100644 index 0000000..e1d3087 --- /dev/null +++ b/module/migrations/v3.0.0.mjs @@ -0,0 +1,72 @@ +import { Logger } from "../utils/Logger.mjs"; +import { migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; + +const flag = `convertAttributesIntoItems`; +const operations = []; + +export async function migrateTo3_0_0() { + Logger.debug(`Starting v3.0.0 data migration`); + + operations.push( + ...await migrateCollection( + game.actors, + flag, + handleMigratingActor, + { update: false, }, + ), + ); + + // for (const pack of game.packs) { + // if ( + // pack.metadata.type !== "Actor" + // || !shouldMigrateCompendium(pack) + // ) { + // continue; + // }; + + // await pack.getDocuments(); + + // // TODO: unlock compendium if required then re-lock after finishing + // await migrateCollection( + // pack, + // flag, + // handleMigratingActor, + // { pack }, + // ); + // }; + + // TODO: create the item documents (batch them if possible) + Logger.debug(`Finished v3.0.0 migration, resulting operations:`); + console.log(operations); +}; + +function handleMigratingActor(actor) { + console.log(actor); + + const operation = { + action: `create`, + documentName: `Item`, + parent: actor, + data: [], + }; + + const attrs = actor.system.attr; + for (const [ key, attr ] of Object.entries(attrs)) { + operation.data.push(convertToItem(key, attr)); + }; + operations.push(operation); + + return null; +}; + +function convertToItem(key, attr) { + return { + name: attr.name, + type: "attribute", + system: { + key, + value: attr.value, + max: attr.isRange ? attr.max : null, + }, + }; +}; diff --git a/module/settings/world.mjs b/module/settings/world.mjs index bb0cd06..a8dd616 100644 --- a/module/settings/world.mjs +++ b/module/settings/world.mjs @@ -74,4 +74,10 @@ export function registerWorldSettings() { type: Object, scope: `world`, }); + + game.settings.register(__ID__, `migrationVersion`, { + config: false, + type: String, + scope: `world`, + }); }; -- 2.49.1 From 85e3838396179a68a8bda6f0a4b8c026adf5e430 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 19 Apr 2026 23:43:12 -0600 Subject: [PATCH 04/50] Add reference comment for the modifyBatch method --- module/migrations/v3.0.0.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index e1d3087..9754b27 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -38,6 +38,7 @@ export async function migrateTo3_0_0() { // TODO: create the item documents (batch them if possible) Logger.debug(`Finished v3.0.0 migration, resulting operations:`); console.log(operations); + // Use: foundry.documents.modifyBatch }; function handleMigratingActor(actor) { -- 2.49.1 From b0a3d972f080444390da4da260f184f2f58184b3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 21:25:48 -0600 Subject: [PATCH 05/50] Add committing of the db operations into the migration lifecycle --- langs/en-ca.json | 7 +++- module/documents/Actor.mjs | 6 ++- module/migrations/utils.mjs | 25 ++++++++---- module/migrations/v3.0.0.mjs | 76 +++++++++++++++++++++++++++--------- 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/langs/en-ca.json b/langs/en-ca.json index 2600103..819956c 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -133,10 +133,13 @@ "missing-id": "An ID must be provided", "invalid-socket": "Invalid socket data received, this means a module or system bug is present.", "unknown-socket-event": "An unknown socket event was received: {event}", - "malformed-socket-payload": "Socket event \"{event}\" received with malformed payload. Details: {details}" + }, + "warn": { + "migration-in-progress": "Applying data migrations for version {version}. Please do NOT refresh the window while this warning is present." }, "success": { - "saved-default-attributes": "Successfully saved default Actor attributes" + "saved-default-attributes": "Successfully saved default Actor attributes", + "migration-successful": "Data migrations for version {version} were successful." } } } diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index 9969f7a..0c97cea 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -34,10 +34,14 @@ export class TAFActor extends Actor { this.#sortedTypes = null; }; - static migrateData(data, ...args) { + static migrateData(data, options) { + if (options.partial) { return } + console.log(`Actor#migrateData`, foundry.utils.deepClone(data), options); if (Object.keys(data.system?.attr ?? {}).length > 0) { + console.log(`attributes exist`) setProperty(data, `flags.${__ID__}.convertAttributesIntoItems`, true); }; + return data; }; // #endregion Lifecycle diff --git a/module/migrations/utils.mjs b/module/migrations/utils.mjs index 807e69a..6950cc2 100644 --- a/module/migrations/utils.mjs +++ b/module/migrations/utils.mjs @@ -23,10 +23,7 @@ export async function migrateCollection( options = {} ) { const toMigrate = collection - .filter(doc => { - console.log(`toMigrate.filter doc`, doc); - return doc.getFlag(__ID__, flag) - }) + .filter(doc => doc.getFlag(__ID__, flag)) .map(doc => { const update = convertor(doc) ?? {}; update[`_id`] = doc._id; @@ -41,8 +38,20 @@ export async function migrateCollection( return update; }) .filter(data => !!data); - // update in increments of 100 - // TODO: optionally return an array of DB operations for modifyBatch + + if (!options.update) { + return [{ + action: `update`, + broadcast: true, + documentName: collection.documentName, + updates: toMigrate, + noHook: true, + pack: options.pack, + parent: options.parent, + }]; + }; + + // Modify in batches of 100 const batches = Math.ceil(toMigrate.length / 100); for (let i = 0; i < batches; i++) { const updateData = toMigrate.slice(i * 100, (i + 1) * 100); @@ -65,9 +74,9 @@ export async function migrateCollection( * @param pack The CompendiumPack document * @returns {boolean} Whether or not the pack should be migrated */ -export function shouldMigrateCompendium(pack) { +export function shouldMigrateCompendium(pack, types = [`Actor`, `Item`]) { // We only care about actor and item migrations - if (!["Actor", "Item"].includes(pack.documentName)) return false; + if (!types.includes(pack.documentName)) return false; // World compendiums should all be migrated, system ones should never by migrated if (pack.metadata.packageType === "world") return true; diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index 9754b27..0aa7f35 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -3,9 +3,24 @@ import { migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; const flag = `convertAttributesIntoItems`; const operations = []; +let compendiumOperations = []; export async function migrateTo3_0_0() { Logger.debug(`Starting v3.0.0 data migration`); + const packsToMigrate = game.packs.filter( + (pack) => shouldMigrateCompendium(pack, [`Actor`]), + ); + const intervalSize = 1 / (packsToMigrate.length + 1); + + const warning = ui.notifications.warn( + "taf.notifs.warn.migration-in-progress", + { + format: { version: `v3.0.0` }, + progress: true, + permanent: true, + console: false, + }, + ); operations.push( ...await migrateCollection( @@ -15,30 +30,41 @@ export async function migrateTo3_0_0() { { update: false, }, ), ); + warning.update({ pct: warning.pct + intervalSize }); - // for (const pack of game.packs) { - // if ( - // pack.metadata.type !== "Actor" - // || !shouldMigrateCompendium(pack) - // ) { - // continue; - // }; + for (const pack of packsToMigrate) { + await pack.getDocuments(); - // await pack.getDocuments(); + const wasLocked = pack.config.locked; + if (wasLocked) pack.configure({ locked: false }); - // // TODO: unlock compendium if required then re-lock after finishing - // await migrateCollection( - // pack, - // flag, - // handleMigratingActor, - // { pack }, - // ); - // }; + compendiumOperations.push( + ...await migrateCollection( + pack, + flag, + handleMigratingActor, + { pack, update: false, }, + ), + ); + + // foundry.documents.modifyBatch(compendiumOperations); + console.log(`compendiumOperations`, compendiumOperations); + + if (wasLocked) await pack.configure({ locked: true }); + + compendiumOperations = []; + warning.update({ pct: warning.pct + intervalSize }); + }; + + // TODO: re-lock packs here? + + warning.update({ pct: 1 }); // TODO: create the item documents (batch them if possible) Logger.debug(`Finished v3.0.0 migration, resulting operations:`); console.log(operations); // Use: foundry.documents.modifyBatch + // await foundry.documents.modifyBatch(operations); }; function handleMigratingActor(actor) { @@ -48,16 +74,27 @@ function handleMigratingActor(actor) { action: `create`, documentName: `Item`, parent: actor, + noHook: true, data: [], }; - const attrs = actor.system.attr; + const attrs = actor.system?.attr ?? {}; for (const [ key, attr ] of Object.entries(attrs)) { operation.data.push(convertToItem(key, attr)); }; - operations.push(operation); - return null; + // No items to create, don't queue the operation + if (operation.data.length > 0) { + if (actor.inCompendium) { + compendiumOperations.push(operation); + } else { + operations.push(operation); + }; + }; + + return { + "system.attr": _del, + }; }; function convertToItem(key, attr) { @@ -68,6 +105,7 @@ function convertToItem(key, attr) { key, value: attr.value, max: attr.isRange ? attr.max : null, + aboveTheFold: true, }, }; }; -- 2.49.1 From 11f9e407a3ea9690fb1652f7698f7913178fde03 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 21:28:07 -0600 Subject: [PATCH 06/50] Temporarily make it so that the attribute items don't cause the sheet to break during rendering --- module/apps/PlayerSheet.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index 3e1be0f..7d1ceae 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -343,6 +343,18 @@ export class PlayerSheet extends }; async _prepareItem(item) { + if (item.type !== "generic") { + return { + uuid: item.uuid, + img: item.img, + name: item.name, + equipped: false, + quantity: 0, + weight: 0, + isExpanded: false, + canExpand: false, + }; + } const ctx = { uuid: item.uuid, img: item.img, -- 2.49.1 From 9269a68aa6eb9071280f99b4da9bcc59cbf244c1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 21:28:57 -0600 Subject: [PATCH 07/50] Switch the TOF into a non-persisted ObjectField --- module/data/Actor/player.mjs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index d0e8dbc..94761d5 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -12,20 +12,21 @@ export class PlayerData extends foundry.abstract.TypeDataModel { nullable: true, initial: null, }), - attr: new fields.TypedObjectField( - new fields.SchemaField({ - name: new fields.StringField({ blank: false, trim: true }), - sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }), - value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }), - max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }), - isRange: new fields.BooleanField({ initial: false, nullable: false }), - }), - { - initial: {}, - nullable: false, - required: true, - }, - ), + // attr: new fields.TypedObjectField( + // new fields.SchemaField({ + // name: new fields.StringField({ blank: false, trim: true }), + // sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }), + // value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }), + // max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }), + // isRange: new fields.BooleanField({ initial: false, nullable: false }), + // }), + // { + // initial: {}, + // nullable: false, + // required: false, + // }, + // ), + attr: new fields.ObjectField({ persisted: false, }), }; }; -- 2.49.1 From 94cdea044b719f89dd636df5f323c4387e015151 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 21:39:30 -0600 Subject: [PATCH 08/50] Bump the version for migration testing --- system.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system.json b/system.json index ea94abd..a301e22 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "taf", "title": "Text-Based Actors", "description": "An intentionally minimalist system that enables you to play rules-light games without getting in your way!", - "version": "2.6.2", + "version": "3.0.0", "download": "", "manifest": "", "url": "https://git.varify.ca/Foundry/taf", -- 2.49.1 From 6b6bb261f8856bd74680d0dfdc759c2fdd982606 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 23:02:30 -0600 Subject: [PATCH 09/50] Remove unused code snippets --- .vscode/handlebars.code-snippets | 37 -------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .vscode/handlebars.code-snippets diff --git a/.vscode/handlebars.code-snippets b/.vscode/handlebars.code-snippets deleted file mode 100644 index e922f13..0000000 --- a/.vscode/handlebars.code-snippets +++ /dev/null @@ -1,37 +0,0 @@ -{ - // Place your foundry.dungeon workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - // Example: - // "Print to console": { - // "scope": "javascript,typescript", - // "prefix": "log", - // "body": [ - // "console.log('$1');", - // "$2" - // ], - // "description": "Log output to console" - // } - "Localization Shortcut (concat)": { - "scope": "handlebars,html", - "prefix": "i18n", - "body": ["localize (concat \"dotdungeon.$1\" $2)"] - }, - "Localization Shortcut (no concat)": { - "scope": "handlebars,html", - "prefix": "i18n", - "body": ["localize \"dotdungeon.$1\""] - }, - "Icon": { - "scope": "handlebars,html", - "prefix": "icon", - "body": [ - "
", - "\t{{{ $2 }}}", - "
" - ] - } -} \ No newline at end of file -- 2.49.1 From 4e1714ee93362fd3fb977b50a5fecf46d68f393d Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 23:13:10 -0600 Subject: [PATCH 10/50] Finish the one-time migration script for loading old worlds --- module/documents/Actor.mjs | 43 +++++++++++++++++++++++++++--------- module/migrations/utils.mjs | 21 ++++++++++++++++-- module/migrations/v3.0.0.mjs | 36 +++++++++++++----------------- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index 0c97cea..13fc3bb 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -1,7 +1,7 @@ import { __ID__ } from "../consts.mjs"; const { Actor } = foundry.documents; -const { hasProperty, setProperty } = foundry.utils; +const { deepClone, hasProperty, setProperty } = foundry.utils; export class TAFActor extends Actor { @@ -33,16 +33,6 @@ export class TAFActor extends Actor { super._onEmbeddedDocumentChange(...args); this.#sortedTypes = null; }; - - static migrateData(data, options) { - if (options.partial) { return } - console.log(`Actor#migrateData`, foundry.utils.deepClone(data), options); - if (Object.keys(data.system?.attr ?? {}).length > 0) { - console.log(`attributes exist`) - setProperty(data, `flags.${__ID__}.convertAttributesIntoItems`, true); - }; - return data; - }; // #endregion Lifecycle // #region Token Attributes @@ -116,4 +106,35 @@ export class TAFActor extends Actor { return this.#sortedTypes = types; }; // #endregion Getters + + // #region Data Migration + /** + * This checks and performs all data migrations that the system requires, some + * of these are one-time only migrations, others of them will happen every time + * an Actor is updated. + */ + static migrateData(data, options) { + this.#migrateToAttributeItems(data, options); + return super.migrateData(data, options); + }; + + /** + * This method handles checking if the Actor has attributes within it's raw + * system data model, which was where attributes were stored originally, if + * it detects the need for a migration, it stores the existing attribute data + * into a flag so that the v3.0.0 migration script can handle creating the + * data and removing the property from the Actor. + */ + static #migrateToAttributeItems(data, options) { + if (options.partial) { return } + const attr = data.system?.attr ?? {}; + if (Object.keys(attr).length > 0) { + setProperty( + data, + `flags.${__ID__}.convertAttributesIntoItems`, + deepClone(attr), + ); + }; + }; + // #endregion Data Migration }; diff --git a/module/migrations/utils.mjs b/module/migrations/utils.mjs index 6950cc2..95f4c0f 100644 --- a/module/migrations/utils.mjs +++ b/module/migrations/utils.mjs @@ -25,7 +25,7 @@ export async function migrateCollection( const toMigrate = collection .filter(doc => doc.getFlag(__ID__, flag)) .map(doc => { - const update = convertor(doc) ?? {}; + const update = convertor(doc, options) ?? {}; update[`_id`] = doc._id; // v13/v14+ compatibility shim @@ -85,4 +85,21 @@ export function shouldMigrateCompendium(pack, types = [`Actor`, `Item`]) { // Module compendiums should only be migrated if they don't have a download or manifest URL const module = game.modules.get(pack.metadata.packageName); return !module.download && !module.manifest; -} +}; + +export function finishMigrationWarning(warning, version) { + warning.update({ pct: 1 }); + setTimeout( + () => { + warning.remove(); + ui.notifications.success( + `taf.notifs.success.migration-successful`, + { + localize: true, + format: { version }, + }, + ); + }, + 3_000, + ); +}; diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index 0aa7f35..50ff931 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -1,5 +1,6 @@ +import { __ID__ } from "../consts.mjs"; import { Logger } from "../utils/Logger.mjs"; -import { migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; +import { finishMigrationWarning, migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; const flag = `convertAttributesIntoItems`; const operations = []; @@ -10,15 +11,14 @@ export async function migrateTo3_0_0() { const packsToMigrate = game.packs.filter( (pack) => shouldMigrateCompendium(pack, [`Actor`]), ); - const intervalSize = 1 / (packsToMigrate.length + 1); const warning = ui.notifications.warn( "taf.notifs.warn.migration-in-progress", { - format: { version: `v3.0.0` }, + localize: true, + format: { version: `3.0.0` }, progress: true, permanent: true, - console: false, }, ); @@ -30,55 +30,49 @@ export async function migrateTo3_0_0() { { update: false, }, ), ); - warning.update({ pct: warning.pct + intervalSize }); + warning.update({ pct: 0.25, }); for (const pack of packsToMigrate) { await pack.getDocuments(); const wasLocked = pack.config.locked; - if (wasLocked) pack.configure({ locked: false }); + if (wasLocked) await pack.configure({ locked: false }); compendiumOperations.push( ...await migrateCollection( pack, flag, handleMigratingActor, - { pack, update: false, }, + { pack: pack.collection, update: false, }, ), ); - // foundry.documents.modifyBatch(compendiumOperations); - console.log(`compendiumOperations`, compendiumOperations); + await foundry.documents.modifyBatch(compendiumOperations); if (wasLocked) await pack.configure({ locked: true }); compendiumOperations = []; - warning.update({ pct: warning.pct + intervalSize }); }; - // TODO: re-lock packs here? + warning.update({ pct: 0.8 }); - warning.update({ pct: 1 }); + await foundry.documents.modifyBatch(operations); - // TODO: create the item documents (batch them if possible) - Logger.debug(`Finished v3.0.0 migration, resulting operations:`); - console.log(operations); - // Use: foundry.documents.modifyBatch - // await foundry.documents.modifyBatch(operations); + finishMigrationWarning(warning, `3.0.0`); }; -function handleMigratingActor(actor) { - console.log(actor); - +function handleMigratingActor(actor, options) { const operation = { action: `create`, + broadcast: true, documentName: `Item`, parent: actor, + pack: options.pack, noHook: true, data: [], }; - const attrs = actor.system?.attr ?? {}; + const attrs = actor.getFlag(__ID__, flag) ?? {}; for (const [ key, attr ] of Object.entries(attrs)) { operation.data.push(convertToItem(key, attr)); }; -- 2.49.1 From e087ba7d42a182f09cb09ad958a2b9d04d2ed5b8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 23:13:24 -0600 Subject: [PATCH 11/50] Make the JSON be valid and actually load in Foundry --- langs/en-ca.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/langs/en-ca.json b/langs/en-ca.json index 819956c..552ca5f 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -132,7 +132,7 @@ "error": { "missing-id": "An ID must be provided", "invalid-socket": "Invalid socket data received, this means a module or system bug is present.", - "unknown-socket-event": "An unknown socket event was received: {event}", + "unknown-socket-event": "An unknown socket event was received: {event}" }, "warn": { "migration-in-progress": "Applying data migrations for version {version}. Please do NOT refresh the window while this warning is present." -- 2.49.1 From 3fff439483e7060216d6979c85a079d75c72ba5b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 23:20:58 -0600 Subject: [PATCH 12/50] Rename "operations" -> "worldOperations" to make it clearer what it's for --- module/migrations/v3.0.0.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index 50ff931..bb9f9ec 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -3,7 +3,7 @@ import { Logger } from "../utils/Logger.mjs"; import { finishMigrationWarning, migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; const flag = `convertAttributesIntoItems`; -const operations = []; +const worldOperations = []; let compendiumOperations = []; export async function migrateTo3_0_0() { @@ -22,7 +22,7 @@ export async function migrateTo3_0_0() { }, ); - operations.push( + worldOperations.push( ...await migrateCollection( game.actors, flag, @@ -56,7 +56,7 @@ export async function migrateTo3_0_0() { warning.update({ pct: 0.8 }); - await foundry.documents.modifyBatch(operations); + await foundry.documents.modifyBatch(worldOperations); finishMigrationWarning(warning, `3.0.0`); }; @@ -82,7 +82,7 @@ function handleMigratingActor(actor, options) { if (actor.inCompendium) { compendiumOperations.push(operation); } else { - operations.push(operation); + worldOperations.push(operation); }; }; -- 2.49.1 From 64610576476d4a0bf441448c36fbb2706995e4be Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Apr 2026 23:22:19 -0600 Subject: [PATCH 13/50] Lint the code that I fairly copy-pasted from draw-steel --- module/data/Actor/player.mjs | 2 +- module/migrations/checkMigrations.mjs | 2 +- module/migrations/utils.mjs | 8 ++++---- module/migrations/v3.0.0.mjs | 20 ++++++++++++-------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index 94761d5..b3e1472 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -26,7 +26,7 @@ export class PlayerData extends foundry.abstract.TypeDataModel { // required: false, // }, // ), - attr: new fields.ObjectField({ persisted: false, }), + attr: new fields.ObjectField({ persisted: false }), }; }; diff --git a/module/migrations/checkMigrations.mjs b/module/migrations/checkMigrations.mjs index 776b25c..585526d 100644 --- a/module/migrations/checkMigrations.mjs +++ b/module/migrations/checkMigrations.mjs @@ -13,7 +13,7 @@ export async function checkMigrations() { const migrationVersion = game.settings.get(__ID__, `migrationVersion`); let updateVersion = !migrationVersion; - if (isNewerVersion("3.0.0", migrationVersion)) { + if (isNewerVersion(`3.0.0`, migrationVersion)) { await migrateTo3_0_0(); updateVersion = true; }; diff --git a/module/migrations/utils.mjs b/module/migrations/utils.mjs index 95f4c0f..1c03767 100644 --- a/module/migrations/utils.mjs +++ b/module/migrations/utils.mjs @@ -20,7 +20,7 @@ export async function migrateCollection( collection, flag, convertor, - options = {} + options = {}, ) { const toMigrate = collection .filter(doc => doc.getFlag(__ID__, flag)) @@ -76,11 +76,11 @@ export async function migrateCollection( */ export function shouldMigrateCompendium(pack, types = [`Actor`, `Item`]) { // We only care about actor and item migrations - if (!types.includes(pack.documentName)) return false; + if (!types.includes(pack.documentName)) {return false} // World compendiums should all be migrated, system ones should never by migrated - if (pack.metadata.packageType === "world") return true; - if (pack.metadata.packageType === "system") return false; + if (pack.metadata.packageType === `world`) {return true} + if (pack.metadata.packageType === `system`) {return false} // Module compendiums should only be migrated if they don't have a download or manifest URL const module = game.modules.get(pack.metadata.packageName); diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index bb9f9ec..3d230ae 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -1,6 +1,10 @@ +import { + finishMigrationWarning, + migrateCollection, + shouldMigrateCompendium, +} from "./utils.mjs"; import { __ID__ } from "../consts.mjs"; import { Logger } from "../utils/Logger.mjs"; -import { finishMigrationWarning, migrateCollection, shouldMigrateCompendium } from "./utils.mjs"; const flag = `convertAttributesIntoItems`; const worldOperations = []; @@ -13,7 +17,7 @@ export async function migrateTo3_0_0() { ); const warning = ui.notifications.warn( - "taf.notifs.warn.migration-in-progress", + `taf.notifs.warn.migration-in-progress`, { localize: true, format: { version: `3.0.0` }, @@ -27,29 +31,29 @@ export async function migrateTo3_0_0() { game.actors, flag, handleMigratingActor, - { update: false, }, + { update: false }, ), ); - warning.update({ pct: 0.25, }); + warning.update({ pct: 0.25 }); for (const pack of packsToMigrate) { await pack.getDocuments(); const wasLocked = pack.config.locked; - if (wasLocked) await pack.configure({ locked: false }); + if (wasLocked) {await pack.configure({ locked: false })} compendiumOperations.push( ...await migrateCollection( pack, flag, handleMigratingActor, - { pack: pack.collection, update: false, }, + { pack: pack.collection, update: false }, ), ); await foundry.documents.modifyBatch(compendiumOperations); - if (wasLocked) await pack.configure({ locked: true }); + if (wasLocked) {await pack.configure({ locked: true })} compendiumOperations = []; }; @@ -94,7 +98,7 @@ function handleMigratingActor(actor, options) { function convertToItem(key, attr) { return { name: attr.name, - type: "attribute", + type: `attribute`, system: { key, value: attr.value, -- 2.49.1 From f558b08c75e612b633b92699d7405c50145ee5bf Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 16:53:04 -0600 Subject: [PATCH 14/50] Add a migration for the world setting to make it use an array of item schemas --- module/migrations/v3.0.0.mjs | 15 +++++++++++++-- module/settings/world.mjs | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/module/migrations/v3.0.0.mjs b/module/migrations/v3.0.0.mjs index 3d230ae..acedfc8 100644 --- a/module/migrations/v3.0.0.mjs +++ b/module/migrations/v3.0.0.mjs @@ -26,6 +26,7 @@ export async function migrateTo3_0_0() { }, ); + // Migrating world actors worldOperations.push( ...await migrateCollection( game.actors, @@ -36,6 +37,7 @@ export async function migrateTo3_0_0() { ); warning.update({ pct: 0.25 }); + // Migrating all of the relevant compendiums for (const pack of packsToMigrate) { await pack.getDocuments(); @@ -57,11 +59,20 @@ export async function migrateTo3_0_0() { compendiumOperations = []; }; - warning.update({ pct: 0.8 }); - await foundry.documents.modifyBatch(worldOperations); + // Migrating the world setting + const defaultAttrs = game.settings.get(__ID__, `actorDefaultAttributes`)?.at(0); + if (defaultAttrs) { + const itemSchemas = []; + for (const [key, attr] of Object.entries(defaultAttrs)) { + itemSchemas.push(convertToItem(key, attr)); + }; + await game.settings.set(__ID__, `actorDefaultAttributes`, itemSchemas); + }; + warning.update({ pct: 0.9 }); + await foundry.documents.modifyBatch(worldOperations); finishMigrationWarning(warning, `3.0.0`); }; diff --git a/module/settings/world.mjs b/module/settings/world.mjs index a8dd616..f811d13 100644 --- a/module/settings/world.mjs +++ b/module/settings/world.mjs @@ -71,7 +71,7 @@ export function registerWorldSettings() { game.settings.register(__ID__, `actorDefaultAttributes`, { config: false, - type: Object, + type: Array, scope: `world`, }); -- 2.49.1 From 3e1f14f957392e1f8e67e30ea33641e6b63ebcd5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 16:57:05 -0600 Subject: [PATCH 15/50] Move the _preCreate logic into the Data Model instead of the Document --- module/data/Actor/player.mjs | 20 ++++++++++++++++++++ module/documents/Actor.mjs | 20 +------------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index b3e1472..c525c9c 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -30,6 +30,26 @@ export class PlayerData extends foundry.abstract.TypeDataModel { }; }; + // #region Lifecycle + /** + * This makes sure that the actor gets created with the global attributes if + * they exist, while still allowing programmatic creation through the API with + * specific attributes. + */ + async _preCreate(data, options, user) { + + // Assign the defaults from the world setting if they exist + const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? {}; + if (!hasProperty(data, `system.attr`)) { + // Remove with issue: Foundry/taf#55 + const value = game.release.generation > 13 ? _replace(defaults) : defaults; + this.updateSource({ "system.==attr": value }); + }; + + return super._preCreate(data, options, user); + }; + // #endregion Lifecycle + get hasAttributes() { return Object.keys(this.attr).length > 0; }; diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index 13fc3bb..98988ce 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -1,29 +1,11 @@ import { __ID__ } from "../consts.mjs"; const { Actor } = foundry.documents; -const { deepClone, hasProperty, setProperty } = foundry.utils; +const { deepClone, setProperty } = foundry.utils; export class TAFActor extends Actor { // #region Lifecycle - /** - * This makes sure that the actor gets created with the global attributes if - * they exist, while still allowing programmatic creation through the API with - * specific attributes. - */ - async _preCreate(data, options, user) { - - // Assign the defaults from the world setting if they exist - const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? {}; - if (!hasProperty(data, `system.attr`)) { - // Remove with issue: Foundry/taf#55 - const value = game.release.generation > 13 ? _replace(defaults) : defaults; - this.updateSource({ "system.==attr": value }); - }; - - return super._preCreate(data, options, user); - }; - /** * This resets the cache of the item groupings whenever a descedant document * gets changed (created, updated, deleted) so that we keep the cache as close -- 2.49.1 From 71fce2c0ca9a25f86597cd316f06fa937d399ad4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 17:30:42 -0600 Subject: [PATCH 16/50] Update the preCreate hook to create the attribute items if there are no items in the actor --- module/data/Actor/player.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index c525c9c..16308c3 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -1,3 +1,5 @@ +import { __ID__ } from "../../consts.mjs"; + export class PlayerData extends foundry.abstract.TypeDataModel { static defineSchema() { const fields = foundry.data.fields; @@ -38,12 +40,11 @@ export class PlayerData extends foundry.abstract.TypeDataModel { */ async _preCreate(data, options, user) { - // Assign the defaults from the world setting if they exist - const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? {}; - if (!hasProperty(data, `system.attr`)) { - // Remove with issue: Foundry/taf#55 - const value = game.release.generation > 13 ? _replace(defaults) : defaults; - this.updateSource({ "system.==attr": value }); + // Assign the default items from the world setting if required + const items = this.parent._source.items; + if (items == null || items.length === 0) { + const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? []; + this.parent.updateSource({ items: defaults }); }; return super._preCreate(data, options, user); -- 2.49.1 From 153f48a7c3c97ecc7bf59348e3cd41cc5ca332bf Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 17:42:32 -0600 Subject: [PATCH 17/50] Improve cloning-detection in the preCreate hook so that we can ignore duplications for creating attribute items --- module/data/Actor/player.mjs | 2 +- module/documents/Actor.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index 16308c3..a2b2df4 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -42,7 +42,7 @@ export class PlayerData extends foundry.abstract.TypeDataModel { // Assign the default items from the world setting if required const items = this.parent._source.items; - if (items == null || items.length === 0) { + if (items.length === 0 && !options.cloning) { const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? []; this.parent.updateSource({ items: defaults }); }; diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index 98988ce..af03605 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -15,6 +15,16 @@ export class TAFActor extends Actor { super._onEmbeddedDocumentChange(...args); this.#sortedTypes = null; }; + + /** + * This override allows the _preCreate operations to see whether the actor is + * being cloned or created from nothing. This allows for easy one-time operations + * that should be performed during Actor creation but not duplication to occur. + */ + clone(data, context) { + context.cloning = true; + return super.clone(data, context); + }; // #endregion Lifecycle // #region Token Attributes -- 2.49.1 From 59d2d57ed74125a0d66d9b6160387e006398f801 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 17:43:32 -0600 Subject: [PATCH 18/50] Remove unused schema property --- module/data/Actor/player.mjs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index a2b2df4..4f07317 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -14,20 +14,6 @@ export class PlayerData extends foundry.abstract.TypeDataModel { nullable: true, initial: null, }), - // attr: new fields.TypedObjectField( - // new fields.SchemaField({ - // name: new fields.StringField({ blank: false, trim: true }), - // sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }), - // value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }), - // max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }), - // isRange: new fields.BooleanField({ initial: false, nullable: false }), - // }), - // { - // initial: {}, - // nullable: false, - // required: false, - // }, - // ), attr: new fields.ObjectField({ persisted: false }), }; }; -- 2.49.1 From 12651938ed717f210d73480e1ff57cf27772c3c8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Apr 2026 23:39:14 -0600 Subject: [PATCH 19/50] Prevent attribute Items from showing up in the Items tab of actor sheets --- module/apps/PlayerSheet.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index 7d1ceae..a2c4aad 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -126,8 +126,16 @@ export class PlayerSheet extends return true; }; + get hasAttributesTab() { + return this.actor.itemTypes.attributes + .filter(attr => !attr.system.aboveTheFold) + .length > 0; + }; + get hasItemsTab() { - return this.actor.items.size > 0; + return this.actor.items + .filter(item => item.type !== `attribute`) + .length > 0; }; // #endregion Instance Data @@ -321,6 +329,10 @@ export class PlayerSheet extends ctx.itemGroups = []; for (const [groupName, items] of Object.entries(this.actor.itemTypes)) { + + // We don't care about attribute items here + if (groupName === `attribute`) { continue }; + const preparedItems = []; let summedWeight = 0; -- 2.49.1 From f932c6d774403567f4b34a9944cbce4c747e1cda Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 22 Apr 2026 17:25:30 -0600 Subject: [PATCH 20/50] Add an EphemeralObjectField that only allows deleting itself from the db and showing up in diffs --- module/data/fields/EphemeralObjectField.mjs | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 module/data/fields/EphemeralObjectField.mjs diff --git a/module/data/fields/EphemeralObjectField.mjs b/module/data/fields/EphemeralObjectField.mjs new file mode 100644 index 0000000..a8315c8 --- /dev/null +++ b/module/data/fields/EphemeralObjectField.mjs @@ -0,0 +1,29 @@ +/** + * This data field functions the same way as Foundry's ObjectField + * however it permits changes to internal properties to be tracked in + * a diff, without committing those changes to the database unless it's + * removing the entire object. + */ +export class EphemeralObjectField extends foundry.data.fields.ObjectField { + + /** + * Inverting the defaults from ObjectField since we don't want it to + * be stored in the database anyway + */ + static get _defaults() { + return Object.assign(super._defaults, { required: false, nullable: true }); + }; + + /** + * When trying to diff the object, only allow deletions to go through, + * otherwise ignore it entirely + */ + _updateDiff(key, value, options, state) { + if ( + !value + || (value instanceof foundry.data.operators.ForcedDeletion) + ) { + return super._updateDiff(key, value, options, state); + }; + }; +}; -- 2.49.1 From b856708b93a5606758286c37f6f721ff7a40cdf9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 22 Apr 2026 17:59:55 -0600 Subject: [PATCH 21/50] Add isRange for an attribute item --- module/data/Item/attribute.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/module/data/Item/attribute.mjs b/module/data/Item/attribute.mjs index e1426a7..afc8999 100644 --- a/module/data/Item/attribute.mjs +++ b/module/data/Item/attribute.mjs @@ -32,4 +32,8 @@ export class AttributeItemData extends foundry.abstract.TypeDataModel { }), }; }; + + get isRange() { + return this.max !== null; + }; }; -- 2.49.1 From 90447de7426435cf91cc2b1fb69e848235ebb9e1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 22 Apr 2026 18:00:50 -0600 Subject: [PATCH 22/50] Add _preUpdate handling to the player actor subtype to forward any attribute updates into the appropriate embedded item --- module/data/Actor/player.mjs | 67 +++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index 4f07317..dfe0821 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -1,6 +1,11 @@ import { __ID__ } from "../../consts.mjs"; +import { Logger } from "../../utils/Logger.mjs"; +import { EphemeralObjectField } from "../fields/EphemeralObjectField.mjs"; + +const { getProperty, hasProperty } = foundry.utils; export class PlayerData extends foundry.abstract.TypeDataModel { + // #region Schema static defineSchema() { const fields = foundry.data.fields; return { @@ -14,15 +19,16 @@ export class PlayerData extends foundry.abstract.TypeDataModel { nullable: true, initial: null, }), - attr: new fields.ObjectField({ persisted: false }), + attr: new EphemeralObjectField({ initial: {} }), }; }; + // #endregion Schema // #region Lifecycle /** - * This makes sure that the actor gets created with the global attributes if - * they exist, while still allowing programmatic creation through the API with - * specific attributes. + * This makes sure that the actor gets created with the global + * attributes if they exist, while still allowing programmatic + * creation through the API with specific attributes. */ async _preCreate(data, options, user) { @@ -35,9 +41,62 @@ export class PlayerData extends foundry.abstract.TypeDataModel { return super._preCreate(data, options, user); }; + + /** + * Ensures that the required data structures exist in order for the + * derived data to be able to populate itself correctly. + */ + prepareBaseData() { + this.attr = {}; + }; + + /** + * For every attribute item that the character has, we want that data + * accessible in the system data, so we create objects dynamically that + * the rest of Foundry can read in to emulate the attributes being on + * the Actor directly. + */ + prepareDerivedData() { + const attrs = this.parent.items?.filter(item => item.type === `attribute`); + for (const attr of attrs) { + if (attr.system.isRange) { + this.attr[attr.system.key] = { + value: attr.system.value, + max: attr.system.max, + }; + } else { + this.attr[attr.system.key] = attr.system.value; + }; + }; + }; + + /** + * This handler makes it so that when a user updates the attributes + * using a "system.attr.*" property they correctly get removed from the + * update and are forwarded to the correct Item document instead + */ + async _preUpdate(data, options, user) { + if (hasProperty(data, `system.attr`)) { + Logger.info(`Forwarding attribute update(s) to embedded Item(s)`); + const items = this.parent.itemTypes?.attribute ?? []; + for (const attr of items) { + const key = `system.attr.${attr.system.key}`; + if (hasProperty(data, key)) { + let value = getProperty(data, key); + if (attr.system.isRange) { + attr.update({ system: value }); + } else { + attr.update({ system: { value }}); + }; + }; + }; + }; + }; // #endregion Lifecycle + // #region Getters get hasAttributes() { return Object.keys(this.attr).length > 0; }; + // #endregion Getters }; -- 2.49.1 From 7970cb64c8a5d12503b91c0f3f53d2264d4a79ea Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 23 Apr 2026 17:03:15 -0600 Subject: [PATCH 23/50] Add clamp utility --- module/utils/clamp.mjs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 module/utils/clamp.mjs diff --git a/module/utils/clamp.mjs b/module/utils/clamp.mjs new file mode 100644 index 0000000..141185b --- /dev/null +++ b/module/utils/clamp.mjs @@ -0,0 +1,5 @@ +export function clamp(min, ideal, max) { + min ??= Number.NEGATIVE_INFINITY; + max ??= Number.POSITIVE_INFINITY; + return Math.max(min, Math.min(ideal, max)); +}; -- 2.49.1 From 5073c972e877b7aba3f1366573663abaa38f626e Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 23 Apr 2026 17:04:17 -0600 Subject: [PATCH 24/50] Rip out the _preUpdate handling, it's complicated and messy to maintain --- module/data/Actor/player.mjs | 37 +------------------ module/documents/Actor.mjs | 70 ++++++++++++++++++++++++++++-------- module/documents/Token.mjs | 22 ++++-------- 3 files changed, 63 insertions(+), 66 deletions(-) diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs index dfe0821..26ed8e1 100644 --- a/module/data/Actor/player.mjs +++ b/module/data/Actor/player.mjs @@ -1,8 +1,4 @@ import { __ID__ } from "../../consts.mjs"; -import { Logger } from "../../utils/Logger.mjs"; -import { EphemeralObjectField } from "../fields/EphemeralObjectField.mjs"; - -const { getProperty, hasProperty } = foundry.utils; export class PlayerData extends foundry.abstract.TypeDataModel { // #region Schema @@ -19,7 +15,7 @@ export class PlayerData extends foundry.abstract.TypeDataModel { nullable: true, initial: null, }), - attr: new EphemeralObjectField({ initial: {} }), + attr: new fields.ObjectField({ persisted: false, initial: {} }), }; }; // #endregion Schema @@ -42,14 +38,6 @@ export class PlayerData extends foundry.abstract.TypeDataModel { return super._preCreate(data, options, user); }; - /** - * Ensures that the required data structures exist in order for the - * derived data to be able to populate itself correctly. - */ - prepareBaseData() { - this.attr = {}; - }; - /** * For every attribute item that the character has, we want that data * accessible in the system data, so we create objects dynamically that @@ -69,29 +57,6 @@ export class PlayerData extends foundry.abstract.TypeDataModel { }; }; }; - - /** - * This handler makes it so that when a user updates the attributes - * using a "system.attr.*" property they correctly get removed from the - * update and are forwarded to the correct Item document instead - */ - async _preUpdate(data, options, user) { - if (hasProperty(data, `system.attr`)) { - Logger.info(`Forwarding attribute update(s) to embedded Item(s)`); - const items = this.parent.itemTypes?.attribute ?? []; - for (const attr of items) { - const key = `system.attr.${attr.system.key}`; - if (hasProperty(data, key)) { - let value = getProperty(data, key); - if (attr.system.isRange) { - attr.update({ system: value }); - } else { - attr.update({ system: { value }}); - }; - }; - }; - }; - }; // #endregion Lifecycle // #region Getters diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs index af03605..22f7881 100644 --- a/module/documents/Actor.mjs +++ b/module/documents/Actor.mjs @@ -1,4 +1,5 @@ import { __ID__ } from "../consts.mjs"; +import { clamp } from "../utils/clamp.mjs"; const { Actor } = foundry.documents; const { deepClone, setProperty } = foundry.utils; @@ -28,7 +29,30 @@ export class TAFActor extends Actor { // #endregion Lifecycle // #region Token Attributes + /** + * @override + * This override exists in order to support making updates to the Actor's + * embedded attribute Items from the token, or falling back to the default + * handling if it's not one of our attributes. + */ async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) { + if (attribute.startsWith(`attr.`)) { + const key = attribute.slice(5); + const attr = this.getAttribute(key); + value = isDelta ? attr.system.value + value : value; + value = clamp(attr.system.min, value, attr.system.max); + const updates = { system: { value } }; + + const allowed = Hooks.call( + `modifyTokenAttribute`, + { attribute, value, isDelta, isBar, isEmbedded: true }, + updates, + this + ); + + return allowed !== false ? await attr.update(updates) : this; + }; + const attr = foundry.utils.getProperty(this.system, attribute); const current = isBar ? attr.value : attr; const update = isDelta ? current + value : value; @@ -36,18 +60,7 @@ export class TAFActor extends Actor { return this; }; - // Determine the updates to make to the actor data - let updates; - if (isBar) { - updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)}; - } else { - updates = {[`system.${attribute}`]: update}; - }; - - // Allow a hook to override these changes - const allowed = Hooks.call(`modifyTokenAttribute`, {attribute, value, isDelta, isBar}, updates, this); - - return allowed !== false ? this.update(updates) : this; + return super.modifyTokenAttribute(attribute, value, isDelta, isBar); }; // #endregion Token Attributes @@ -80,8 +93,15 @@ export class TAFActor extends Actor { }; // #endregion Roll Data - // #region Getters + // #region Methods #sortedTypes = null; + /** + * @override + * This override is intended to allow the "generic" item subtype to instead + * populate the Item types based on their "Group" property, for any other item + * subtype this function operates the same way that the default Foundry + * implementation does. + */ get itemTypes() { if (this.#sortedTypes) { return this.#sortedTypes }; const types = {}; @@ -97,7 +117,29 @@ export class TAFActor extends Actor { }; return this.#sortedTypes = types; }; - // #endregion Getters + + /** + * Retrieves an attribute Item from the actor, used to more easily + * + * @param {string} key The unique identifier of the attribute + * @returns The attribute's Item document, or undefined if not found + */ + getAttribute(key) { + const attrs = this.itemTypes.attribute ?? []; + return attrs.find(attr => attr.system.key === key); + }; + + /** + * Updates an embedded attribute Item with a new value. + * + * @param {string} key The unique identifier of the attribute + * @param {number} value The value to set the attribute to + */ + async setAttributeValue(key, value) { + const item = this.getAttribute(key); + await item?.update({system: { value }}); + }; + // #endregion Methods // #region Data Migration /** diff --git a/module/documents/Token.mjs b/module/documents/Token.mjs index 068c737..8b0b5bd 100644 --- a/module/documents/Token.mjs +++ b/module/documents/Token.mjs @@ -83,22 +83,12 @@ export class TAFTokenDocument extends TokenDocument { if (`value` in data && `max` in data) { let editable = hasProperty(system, `${attribute}.value`); - const isRange = getProperty(system, `${attribute}.isRange`); - if (isRange) { - return { - type: `bar`, - attribute, - value: parseInt(data.value || 0), - max: parseInt(data.max || 0), - editable, - }; - } else { - return { - type: `value`, - attribute: `${attribute}.value`, - value: Number(data.value), - editable, - }; + return { + type: `bar`, + attribute, + value: parseInt(data.value || 0), + max: parseInt(data.max || 0), + editable, }; }; -- 2.49.1 From 2c915c82e886e5be2218633cbe139d55e7fabc73 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 23 Apr 2026 17:20:12 -0600 Subject: [PATCH 25/50] Fix a bug with the item groups having the formatted totalWeight instead of group-only weight --- module/apps/PlayerSheet.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index a2c4aad..2ae276d 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -345,7 +345,7 @@ export class PlayerSheet extends ctx.itemGroups.push({ name: groupName.titleCase(), items: preparedItems, - weight: config.weightFormatter(totalWeight), + weight: config.weightFormatter(summedWeight), }); }; -- 2.49.1 From f1499d1c3da100cdcc5f2e45a1aab7e49059653e Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 23 Apr 2026 17:46:40 -0600 Subject: [PATCH 26/50] Get started on the required infrastructure for the attributes tab --- langs/en-ca.json | 1 + module/apps/PlayerSheet.mjs | 57 ++++++++++---------- templates/PlayerSheet/attributes.hbs | 34 ------------ templates/PlayerSheet/primary-attributes.hbs | 51 ++++++++++++++++++ 4 files changed, 79 insertions(+), 64 deletions(-) delete mode 100644 templates/PlayerSheet/attributes.hbs create mode 100644 templates/PlayerSheet/primary-attributes.hbs diff --git a/langs/en-ca.json b/langs/en-ca.json index 552ca5f..b0418db 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -97,6 +97,7 @@ "toggle-item-description": "Show/Hide Item Description", "tab-names": { "content": "Content", + "attributes": "Attributes", "items": "Items" } }, diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index 2ae276d..9b55b93 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -45,9 +45,10 @@ export class PlayerSheet extends static PARTS = { header: { template: filePath(`templates/PlayerSheet/header.hbs`) }, - attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) }, + primaryAttributes: { template: filePath(`templates/PlayerSheet/primary-attributes.hbs`) }, tabs: { template: filePath(`templates/generic/tabs.hbs`) }, content: { template: filePath(`templates/PlayerSheet/content.hbs`) }, + attributeTab: {}, items: { template: filePath(`templates/PlayerSheet/item-lists.hbs`), scrollable: [``], @@ -78,6 +79,7 @@ export class PlayerSheet extends labelPrefix: `taf.Apps.PlayerSheet.tab-names`, tabs: [ { id: `content` }, + { id: `attributes` }, { id: `items` }, ], }, @@ -104,6 +106,10 @@ export class PlayerSheet extends Logger.debug(`Asserting app "${this.id}" from tab "items" to "${initial}"`); this.tabGroups.primary = initial; }; + if (this.tabGroups.primary === `attributes` && !this.hasAttributesTab) { + Logger.debug(`Asserting app "${this.id}" from tab "attributes" to "${initial}"`); + this.tabGroups.primary = initial; + } }; /** @@ -118,6 +124,7 @@ export class PlayerSheet extends switch (tabID) { case `content`: return this.hasContentTab; case `items`: return this.hasItemsTab; + case `attributes`: return this.hasAttributesTab; }; return false; }; @@ -127,7 +134,8 @@ export class PlayerSheet extends }; get hasAttributesTab() { - return this.actor.itemTypes.attributes + return this.actor.itemTypes + .attribute .filter(attr => !attr.system.aboveTheFold) .length > 0; }; @@ -262,8 +270,12 @@ export class PlayerSheet extends async _preparePartContext(partID, ctx) { switch (partID) { - case `attributes`: { - await this._prepareAttributes(ctx); + case `primaryAttributes`: { + await this._preparePrimaryAttributes(ctx); + break; + }; + case `attributeTab`: { + await this._prepareAttributesTab(ctx); break; }; case `tabs`: { @@ -283,20 +295,15 @@ export class PlayerSheet extends return ctx; }; - async _prepareAttributes(ctx) { - ctx.hasAttributes = this.actor.system.hasAttributes; - - const attrs = []; - for (const [id, data] of Object.entries(this.actor.system.attr)) { - attrs.push({ - ...data, - id, - path: `system.attr.${id}`, - }); - }; - ctx.attrs = attrs.toSorted(attributeSorter); + async _preparePrimaryAttributes(ctx) { + const attrs = this.actor.itemTypes.attribute ?? []; + const filtered = attrs.filter(attr => attr.system.aboveTheFold); + ctx.hasAttributes = filtered.length > 0; + ctx.attrs = filtered; }; + async _prepareAttributesTab(ctx) {}; + async _prepareTabList(ctx) { ctx.tabs = await this._prepareTabs(`primary`); @@ -337,8 +344,9 @@ export class PlayerSheet extends let summedWeight = 0; for (const item of items) { - summedWeight += item.system.quantifiedWeight; - preparedItems.push(await this._prepareItem(item)); + summedWeight += item.system.quantifiedWeight ?? 0; + const data = await this._prepareItem(item); + if (data) preparedItems.push(data); }; totalWeight += summedWeight; @@ -355,18 +363,7 @@ export class PlayerSheet extends }; async _prepareItem(item) { - if (item.type !== "generic") { - return { - uuid: item.uuid, - img: item.img, - name: item.name, - equipped: false, - quantity: 0, - weight: 0, - isExpanded: false, - canExpand: false, - }; - } + if (item.type !== `generic`) { return }; const ctx = { uuid: item.uuid, img: item.img, diff --git a/templates/PlayerSheet/attributes.hbs b/templates/PlayerSheet/attributes.hbs deleted file mode 100644 index a2f0457..0000000 --- a/templates/PlayerSheet/attributes.hbs +++ /dev/null @@ -1,34 +0,0 @@ -{{#if hasAttributes}} -
- {{#each attrs as | attr |}} -
- - {{ attr.name }} - -
- - {{#if attr.isRange}} - - - {{/if}} -
-
- {{/each}} -
-{{else}} -