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
diff --git a/langs/en-ca.json b/langs/en-ca.json
index 2600103..0c99bc6 100644
--- a/langs/en-ca.json
+++ b/langs/en-ca.json
@@ -4,7 +4,8 @@
"player": "Player"
},
"Item": {
- "generic": "Generic Item"
+ "generic": "Item",
+ "attribute": "Attribute"
}
},
"taf": {
@@ -48,7 +49,8 @@
"PlayerSheet": "Player Sheet",
"SingleModePlayerSheet": "Player Sheet (Always Editing)",
"AttributeOnlyPlayerSheet": "Player Sheet (Attributes Only)",
- "GenericItemSheet": "System Item Sheet"
+ "GenericItemSheet": "System Item Sheet",
+ "AttributeItemSheet": "Attribute Sheet"
},
"misc": {
"Key": "Key",
@@ -67,6 +69,15 @@
"quantity": "Quantity",
"equipped": "Equipped",
"group": "Group"
+ },
+ "attribute": {
+ "key": {
+ "hint": "This is the computer-friendly identifier for the attribute. When accessing the attribute in rolls, this is the name you will need to use. Changing this WILL break any existing macros you have."
+ },
+ "always-visible": "Always Visible",
+ "minimum": "Minimum",
+ "current-value": "Current Value",
+ "maximum": "Maximum"
}
},
"Apps": {
@@ -97,6 +108,7 @@
"toggle-item-description": "Show/Hide Item Description",
"tab-names": {
"content": "Content",
+ "attributes": "Attributes",
"items": "Items"
}
},
@@ -133,10 +145,15 @@
"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}"
+ "duplicate-attribute-key": "Cannot create an Attribute with a key (\"{key}\") that already exists on the Actor",
+ "invalid-attribute-key": "Attribute keys must be alphanumeric (a-z, 0-9) with underscores, invalid key provided: \"{key}\""
+ },
+ "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/api.mjs b/module/api.mjs
index d0b8c62..698f141 100644
--- a/module/api.mjs
+++ b/module/api.mjs
@@ -5,11 +5,11 @@ import { PlayerSheet } from "./apps/PlayerSheet.mjs";
import { QueryStatus } from "./apps/QueryStatus.mjs";
// Utils
+import { isValidID, toID } from "./utils/toID.mjs";
import { attributeSorter } from "./utils/attributeSort.mjs";
import { DialogManager } from "./utils/DialogManager.mjs";
import { localizer } from "./utils/localizer.mjs";
import { QueryManager } from "./utils/QueryManager.mjs";
-import { toID } from "./utils/toID.mjs";
const { deepFreeze } = foundry.utils;
@@ -26,5 +26,6 @@ export const api = deepFreeze({
attributeSorter,
localizer,
toID,
+ isValidID,
},
});
diff --git a/module/apps/AttributeItemSheet.mjs b/module/apps/AttributeItemSheet.mjs
new file mode 100644
index 0000000..bc33f6c
--- /dev/null
+++ b/module/apps/AttributeItemSheet.mjs
@@ -0,0 +1,74 @@
+import { __ID__, filePath } from "../consts.mjs";
+import { TAFDocumentSheetMixin } from "./mixins/TAFDocumentSheetMixin.mjs";
+
+const { HandlebarsApplicationMixin } = foundry.applications.api;
+const { ItemSheetV2 } = foundry.applications.sheets;
+
+export class AttributeItemSheet extends
+ TAFDocumentSheetMixin(
+ HandlebarsApplicationMixin(
+ ItemSheetV2,
+)) {
+ // #region Options
+ static DEFAULT_OPTIONS = {
+ classes: [
+ __ID__,
+ `AttributeItemSheet`,
+ ],
+ position: {
+ width: 350,
+ height: `auto`,
+ },
+ window: {
+ resizable: true,
+ },
+ form: {
+ submitOnChange: true,
+ closeOnSubmit: false,
+ },
+ actions: {},
+ };
+
+ static PARTS = {
+ header: { template: filePath(`templates/AttributeItemSheet/header.hbs`) },
+ value: { template: filePath(`templates/AttributeItemSheet/value.hbs`) },
+ settings: { template: filePath(`templates/AttributeItemSheet/settings.hbs`) },
+ };
+
+ /**
+ * This tells the Application's TAFDocumentSheetMixin how to rerender
+ * this app when specific properties get changed on the actor, so that
+ * it doesn't need to full-app rendering if we can do a partial
+ * rerender instead.
+ */
+ static PROPERTY_TO_PARTIAL = {
+ "name": [`header`],
+ "system.value": [`value`],
+ "system.min": [`value`],
+ "system.max": [`value`],
+ "system.aboveTheFold": [`settings`],
+ "system.group": [`settings`],
+ "system.key": [`settings`],
+ };
+ // #endregion Options
+
+ // #region Instance Data
+ // #endregion Instance Data
+
+ // #region Lifecycle
+ async _prepareContext() {
+ return {
+ meta: {
+ idp: this.id,
+ editable: this.isEditable,
+ limited: this.isLimited,
+ },
+ item: this.item,
+ system: this.item.system,
+ };
+ };
+ // #endregion Lifecycle
+
+ // #region Actions
+ // #endregion Actions
+};
diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs
index 3e1be0f..2e3da97 100644
--- a/module/apps/PlayerSheet.mjs
+++ b/module/apps/PlayerSheet.mjs
@@ -1,7 +1,6 @@
import { __ID__, filePath } from "../consts.mjs";
import { deleteItemFromElement, editItemFromElement } from "./utils.mjs";
import { AttributeManager } from "./AttributeManager.mjs";
-import { attributeSorter } from "../utils/attributeSort.mjs";
import { config } from "../config.mjs";
import { Logger } from "../utils/Logger.mjs";
import { TAFDocumentSheetConfig } from "./TAFDocumentSheetConfig.mjs";
@@ -45,14 +44,21 @@ 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`) },
- items: {
- template: filePath(`templates/PlayerSheet/item-lists.hbs`),
+ attributeTab: {
+ template: filePath(`templates/PlayerSheet/tabs/attributes/lists.hbs`),
scrollable: [``],
templates: [
- filePath(`templates/PlayerSheet/item.hbs`),
+ filePath(`templates/PlayerSheet/tabs/attributes/attribute.hbs`),
+ ],
+ },
+ items: {
+ template: filePath(`templates/PlayerSheet/tabs/items/lists.hbs`),
+ scrollable: [``],
+ templates: [
+ filePath(`templates/PlayerSheet/tabs/items/item.hbs`),
],
},
};
@@ -78,6 +84,7 @@ export class PlayerSheet extends
labelPrefix: `taf.Apps.PlayerSheet.tab-names`,
tabs: [
{ id: `content` },
+ { id: `attributes` },
{ id: `items` },
],
},
@@ -104,6 +111,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 +129,7 @@ export class PlayerSheet extends
switch (tabID) {
case `content`: return this.hasContentTab;
case `items`: return this.hasItemsTab;
+ case `attributes`: return this.hasAttributesTab;
};
return false;
};
@@ -126,8 +138,17 @@ export class PlayerSheet extends
return true;
};
+ get hasAttributesTab() {
+ return this.actor.itemTypes
+ .attribute
+ ?.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
@@ -207,7 +228,7 @@ export class PlayerSheet extends
new ContextMenu.implementation(
this.element,
- `li.item`,
+ `[data-item-uuid]`,
[
{
label: _loc(`taf.misc.edit`),
@@ -254,8 +275,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`: {
@@ -275,18 +300,35 @@ export class PlayerSheet extends
return ctx;
};
- async _prepareAttributes(ctx) {
- ctx.hasAttributes = this.actor.system.hasAttributes;
+ 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;
+ };
- const attrs = [];
- for (const [id, data] of Object.entries(this.actor.system.attr)) {
- attrs.push({
- ...data,
- id,
- path: `system.attr.${id}`,
- });
+ async _prepareAttributesTab(ctx) {
+ ctx.tabActive = this.tabGroups.primary === `attributes`;
+
+ const groups = new Map();
+ const attrs = (this.actor.itemTypes.attribute ?? [])
+ .toSorted((a, b) => a.name.localeCompare(b.name));
+ for (const attr of attrs) {
+ if (attr.system.aboveTheFold) { continue };
+
+ const groupName = attr.system.group ?? `Attributes`;
+ if (!groups.has(groupName)) {
+ groups.set(groupName, {
+ name: groupName.titleCase(),
+ attrs: [],
+ collapsed: false,
+ });
+ };
+ const group = groups.get(groupName);
+
+ group.attrs.push(attr);
};
- ctx.attrs = attrs.toSorted(attributeSorter);
+ ctx.attrGroups = [...groups.values()].toSorted((a, b) => a.name.localeCompare(b.name));
};
async _prepareTabList(ctx) {
@@ -321,19 +363,24 @@ 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;
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;
ctx.itemGroups.push({
name: groupName.titleCase(),
items: preparedItems,
- weight: config.weightFormatter(totalWeight),
+ weight: config.weightFormatter(summedWeight),
});
};
@@ -343,6 +390,7 @@ export class PlayerSheet extends
};
async _prepareItem(item) {
+ if (item.type !== `generic`) { return };
const ctx = {
uuid: item.uuid,
img: item.img,
diff --git a/module/data/Actor/player.mjs b/module/data/Actor/player.mjs
index d0e8dbc..81a197e 100644
--- a/module/data/Actor/player.mjs
+++ b/module/data/Actor/player.mjs
@@ -1,4 +1,7 @@
+import { __ID__ } from "../../consts.mjs";
+
export class PlayerData extends foundry.abstract.TypeDataModel {
+ // #region Schema
static defineSchema() {
const fields = foundry.data.fields;
return {
@@ -12,24 +15,53 @@ 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.ObjectField({ persisted: false, 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.
+ */
+ async _preCreate(data, options, user) {
+
+ // Assign the default items from the world setting if required
+ const items = this.parent._source.items;
+ if (items.length === 0 && !options.cloning) {
+ const defaults = game.settings.get(__ID__, `actorDefaultAttributes`) ?? [];
+ this.parent.updateSource({ items: defaults });
+ };
+
+ return super._preCreate(data, options, user);
+ };
+
+ /**
+ * 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;
+ };
+ };
+ };
+ // #endregion Lifecycle
+
+ // #region Methods
get hasAttributes() {
return Object.keys(this.attr).length > 0;
};
+ // #endregion Methods
};
diff --git a/module/data/Item/attribute.mjs b/module/data/Item/attribute.mjs
new file mode 100644
index 0000000..91ebcb5
--- /dev/null
+++ b/module/data/Item/attribute.mjs
@@ -0,0 +1,112 @@
+import { isValidID, toID } from "../../utils/toID.mjs";
+import { clamp } from "../../utils/clamp.mjs";
+
+const { getProperty, hasProperty, setProperty } = foundry.utils;
+
+export class AttributeItemData extends foundry.abstract.TypeDataModel {
+ // #region Schema
+ 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: false,
+ }),
+ 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,
+ }),
+ };
+ };
+ // #endregion Schema
+
+ // #region Lifecycle
+ async _preCreate(data, options, user) {
+ // Assign the key as the ID'd name if isn't provided, or validate if
+ // it is provided.
+ if (!this.key) {
+ this.updateSource({ key: toID(this.parent.name) });
+ } else if (!isValidID(this.key)) {
+ ui.notifications.error(_loc(
+ `taf.notifs.error.invalid-attribute-key`,
+ { key: this.key },
+ ));
+ return false;
+ };
+
+ // Prevent duplicate Attribute keys from existing on a single Actor
+ if (this.parent.isEmbedded) {
+ const attr = this.parent.parent?.getAttribute(this.key);
+ if (attr) {
+ ui.notifications.error(
+ `taf.notifs.error.duplicate-attribute-key`,
+ {
+ localize: true,
+ format: { key: this.key },
+ },
+ );
+ return false;
+ };
+ };
+
+ return super._preCreate(data, options, user);
+ };
+
+ async _preUpdate(data, options, user) {
+ const allowed = await super._preUpdate(data, options, user);
+ if (allowed === false) { return false };
+
+ // Prevent invalid IDs
+ if (hasProperty(data, `system.key`) && !isValidID(data.system.key)) {
+ ui.notifications.error(_loc(
+ `taf.notifs.error.invalid-attribute-key`,
+ { key: data.system.key },
+ ));
+ delete data.system?.key;
+ };
+
+ // Prevent value going out of the bounds of min/max
+ if (hasProperty(data, `system.value`)) {
+ const value = getProperty(data, `system.value`);
+ const max = getProperty(data, `system.max`) ?? this.max;
+
+ let min = getProperty(data, `system.min`) ?? this.min;
+ if (max != null) { min ??= 0 };
+
+ setProperty(data, `system.value`, clamp(min, value, max));
+ };
+ };
+ // #endregion Lifecycle
+
+ // #region Methods
+ get isRange() {
+ return this.max !== null;
+ };
+
+ get inferredMinimum() {
+ if (this.isRange) {
+ return this.min ?? 0;
+ };
+ return null;
+ };
+ // #endregion Methods
+};
diff --git a/module/documents/Actor.mjs b/module/documents/Actor.mjs
index acbe5e0..ac58f4b 100644
--- a/module/documents/Actor.mjs
+++ b/module/documents/Actor.mjs
@@ -1,29 +1,12 @@
import { __ID__ } from "../consts.mjs";
+import { clamp } from "../utils/clamp.mjs";
const { Actor } = foundry.documents;
-const { hasProperty } = 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
@@ -33,10 +16,43 @@ 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
+ /**
+ * @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;
@@ -44,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
@@ -88,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 = {};
@@ -105,5 +117,58 @@ 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
+ /**
+ * 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/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,
};
};
diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs
index a6f623e..54190fc 100644
--- a/module/hooks/init.mjs
+++ b/module/hooks/init.mjs
@@ -1,10 +1,12 @@
// Apps
+import { AttributeItemSheet } from "../apps/AttributeItemSheet.mjs";
import { AttributeOnlyPlayerSheet } from "../apps/AttributeOnlyPlayerSheet.mjs";
import { GenericItemSheet } from "../apps/GenericItemSheet.mjs";
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 +37,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,10 +64,20 @@ Hooks.on(`init`, () => {
__ID__,
GenericItemSheet,
{
+ types: [`generic`],
makeDefault: true,
label: `taf.sheet-names.GenericItemSheet`,
},
);
+ foundry.documents.collections.Items.registerSheet(
+ __ID__,
+ AttributeItemSheet,
+ {
+ types: [`attribute`],
+ makeDefault: true,
+ label: `taf.sheet-names.AttributeItemSheet`,
+ },
+ );
// #endregion Sheets
registerWorldSettings();
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..585526d
--- /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..1c03767
--- /dev/null
+++ b/module/migrations/utils.mjs
@@ -0,0 +1,105 @@
+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 => doc.getFlag(__ID__, flag))
+ .map(doc => {
+ const update = convertor(doc, options) ?? {};
+ 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);
+
+ 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);
+ 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, types = [`Actor`, `Item`]) {
+ // We only care about actor and item migrations
+ 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}
+
+ // 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
new file mode 100644
index 0000000..acedfc8
--- /dev/null
+++ b/module/migrations/v3.0.0.mjs
@@ -0,0 +1,120 @@
+import {
+ finishMigrationWarning,
+ migrateCollection,
+ shouldMigrateCompendium,
+} from "./utils.mjs";
+import { __ID__ } from "../consts.mjs";
+import { Logger } from "../utils/Logger.mjs";
+
+const flag = `convertAttributesIntoItems`;
+const worldOperations = [];
+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 warning = ui.notifications.warn(
+ `taf.notifs.warn.migration-in-progress`,
+ {
+ localize: true,
+ format: { version: `3.0.0` },
+ progress: true,
+ permanent: true,
+ },
+ );
+
+ // Migrating world actors
+ worldOperations.push(
+ ...await migrateCollection(
+ game.actors,
+ flag,
+ handleMigratingActor,
+ { update: false },
+ ),
+ );
+ warning.update({ pct: 0.25 });
+
+ // Migrating all of the relevant compendiums
+ for (const pack of packsToMigrate) {
+ await pack.getDocuments();
+
+ const wasLocked = pack.config.locked;
+ if (wasLocked) {await pack.configure({ locked: false })}
+
+ compendiumOperations.push(
+ ...await migrateCollection(
+ pack,
+ flag,
+ handleMigratingActor,
+ { pack: pack.collection, update: false },
+ ),
+ );
+
+ await foundry.documents.modifyBatch(compendiumOperations);
+
+ if (wasLocked) {await pack.configure({ locked: true })}
+
+ compendiumOperations = [];
+ };
+ warning.update({ pct: 0.8 });
+
+ // 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`);
+};
+
+function handleMigratingActor(actor, options) {
+ const operation = {
+ action: `create`,
+ broadcast: true,
+ documentName: `Item`,
+ parent: actor,
+ pack: options.pack,
+ noHook: true,
+ data: [],
+ };
+
+ const attrs = actor.getFlag(__ID__, flag) ?? {};
+ for (const [ key, attr ] of Object.entries(attrs)) {
+ operation.data.push(convertToItem(key, attr));
+ };
+
+ // No items to create, don't queue the operation
+ if (operation.data.length > 0) {
+ if (actor.inCompendium) {
+ compendiumOperations.push(operation);
+ } else {
+ worldOperations.push(operation);
+ };
+ };
+
+ return {
+ "system.attr": _del,
+ };
+};
+
+function convertToItem(key, attr) {
+ return {
+ name: attr.name,
+ type: `attribute`,
+ system: {
+ key,
+ value: attr.value,
+ max: attr.isRange ? attr.max : null,
+ aboveTheFold: true,
+ },
+ };
+};
diff --git a/module/settings/world.mjs b/module/settings/world.mjs
index bb0cd06..f811d13 100644
--- a/module/settings/world.mjs
+++ b/module/settings/world.mjs
@@ -71,7 +71,13 @@ export function registerWorldSettings() {
game.settings.register(__ID__, `actorDefaultAttributes`, {
config: false,
- type: Object,
+ type: Array,
+ scope: `world`,
+ });
+
+ game.settings.register(__ID__, `migrationVersion`, {
+ config: false,
+ type: String,
scope: `world`,
});
};
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));
+};
diff --git a/module/utils/toID.mjs b/module/utils/toID.mjs
index 312766e..8e854b1 100644
--- a/module/utils/toID.mjs
+++ b/module/utils/toID.mjs
@@ -1,6 +1,6 @@
/**
- * A helper method that converts an arbitrary string into a format that can be
- * used as an object key easily.
+ * A helper method that converts an arbitrary string into a format
+ * that can be used as an object key easily.
*
* @param {string} text The text to convert
* @returns The converted ID
@@ -9,5 +9,26 @@ export function toID(text) {
return text
.toLowerCase()
.replace(/\s+/g, `_`)
- .replace(/\W/g, ``);
+ .replace(/\W/g, ``)
+ .replace(/(^_|_$)/);
+};
+
+/**
+ * A helper method that reports if an arbitrary string is considered a
+ * valid ID for use in the system
+ *
+ * @param {string} text The text to check
+ * @returns Whether or not the text is a valid ID
+ */
+export function isValidID(text) {
+ return !(
+ // any uppercase characters
+ text.match(/[A-Z]/)
+
+ // any non-word characters
+ || text.match(/\W/)
+
+ // any whitespace characters
+ || text.match(/\s/)
+ );
};
diff --git a/styles/Apps/AttributeItemSheet.css b/styles/Apps/AttributeItemSheet.css
new file mode 100644
index 0000000..6253b6a
--- /dev/null
+++ b/styles/Apps/AttributeItemSheet.css
@@ -0,0 +1,56 @@
+.taf.AttributeItemSheet {
+ min-width: 300px;
+
+ > .window-content {
+ padding: 0;
+ color: var(--attribute-sheet-colour);
+ background: var(--attribute-sheet-background);
+ }
+
+ .sheet-header {
+ padding: 0.5rem;
+ border-bottom: 1px solid var(--attribute-sheet-divider-colour);
+ }
+
+ .value-controls {
+ display: grid;
+ align-items: center;
+ grid-auto-flow: column;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(2, auto);
+ gap: 2px 4px;
+ padding: 0 8px;
+ }
+
+ .property {
+ display: grid;
+ align-items: center;
+ justify-items: left;
+ grid-template-columns: 2fr 1fr;
+ gap: 2px 8px;
+ margin: 0 8px 8px;
+
+ .hint {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: var(--attribute-sheet-hint-colour);
+ }
+ }
+
+ input {
+ color: var(--attribute-sheet-input-colour);
+ background: var(--attribute-sheet-input-background);
+
+ &:disabled {
+ color: var(--attribute-sheet-disabled-input-colour);
+ cursor: not-allowed;
+ }
+ }
+
+ taf-toggle {
+ --toggle-background: var(--attribute-sheet-input-background);
+ --slider-checked-colour: var(--attribute-sheet-toggle-slider-enabled-colour);
+ --slider-unchecked-colour: var(--attribute-sheet-toggle-slider-disabled-colour);
+ justify-self: right;
+ }
+}
diff --git a/styles/Apps/PlayerSheet.css b/styles/Apps/PlayerSheet.css
index 627932e..f83f335 100644
--- a/styles/Apps/PlayerSheet.css
+++ b/styles/Apps/PlayerSheet.css
@@ -37,7 +37,8 @@
}
}
- .items-tab.active {
+ .items-tab.active,
+ .attributes-tab.active {
display: flex;
flex-direction: column;
gap: 8px;
@@ -67,7 +68,7 @@
}
}
- .item-list-header {
+ .embedded-list-header {
display: flex;
flex-direction: row;
align-items: center;
@@ -75,8 +76,8 @@
border-radius: 6px 6px 0 0;
padding: 6px 6px 4px;
margin-bottom: 2px;
- background: var(--item-list-header-background);
- color: var(--item-list-header-colour);
+ background: var(--embedded-list-header-background);
+ color: var(--embedded-list-header-colour);
button {
padding: 2px;
@@ -84,18 +85,23 @@
aspect-ratio: 1;
height: unset;
min-height: unset;
- background: var(--item-list-header-input-background);
- color: var(--item-list-header-input-colour);
+ background: var(--embedded-list-header-input-background);
+ color: var(--embedded-list-header-input-colour);
}
}
- .item-list {
+ .embedded-list {
display: flex;
flex-direction: column;
gap: 2px;
list-style: none;
margin: 0;
padding: 0;
+
+ &.two-col {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ }
}
.item {
@@ -172,6 +178,46 @@
}
}
+ .attribute {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ background: var(--attribute-background);
+ color: var(--attribute-colour);
+ padding: 4px;
+ margin: 0;
+
+ .name {
+ font-size: 1.1rem;
+ }
+
+ input {
+ width: 50px;
+ }
+
+ input, button {
+ background: var(--item-card-header-input-background);
+ color: var(--item-card-header-input-colour);
+ text-align: center;
+
+ &:disabled {
+ color: var(--item-card-header-disabled-input-colour);
+ }
+ }
+
+ &:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+ border-radius: 0 0 6px 6px;
+ }
+ &:nth-last-child(2):has( + &:nth-child(even)) {
+ border-radius: 0 0 0 6px;
+ }
+ &:last-child:nth-child(even) {
+ border-radius: 0 0 6px 0;
+ }
+ }
+
.content {
flex-grow: 1;
overflow: hidden;
diff --git a/styles/main.css b/styles/main.css
index 49e4763..be8ba70 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -26,6 +26,7 @@
/* Apps */
@import url("./Apps/common.css") layer(apps);
@import url("./Apps/Ask.css") layer(apps);
+@import url("./Apps/AttributeItemSheet.css") layer(apps);
@import url("./Apps/AttributeManager.css") layer(apps);
@import url("./Apps/GenericItemSheet.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps);
diff --git a/styles/themes/dark.css b/styles/themes/dark.css
index fa87c25..3d008e2 100644
--- a/styles/themes/dark.css
+++ b/styles/themes/dark.css
@@ -19,10 +19,16 @@
--inventory-input-colour: var(--steel-100);
--inventory-input-disabled-colour: var(--steel-350);
- --item-list-header-background: var(--steel-800);
- --item-list-header-colour: var(--steel-100);
- --item-list-header-input-background: var(--steel-650);
- --item-list-header-input-colour: var(--steel-100);
+ --embedded-list-header-background: var(--steel-800);
+ --embedded-list-header-colour: var(--steel-100);
+ --embedded-list-header-input-background: var(--steel-650);
+ --embedded-list-header-input-colour: var(--steel-100);
+
+ --attribute-background: var(--steel-700);
+ --attribute-colour: var(--steel-100);
+ --attribute-input-background: var(--steel-650);
+ --attribute-input-colour: var(--steel-100);
+ --attribute-disabled-input-colour: var(--steel-350);
--item-card-background: #1d262f;
--item-card-colour: var(--steel-100);
@@ -44,6 +50,17 @@
--item-sheet-description-menu-background: var(--steel-700);
--item-sheet-description-content-background: var(--steel-650);
+ /* Attribute Sheet Variables */
+ --attribute-sheet-colour: var(--item-sheet-colour);
+ --attribute-sheet-background: var(--item-sheet-background);
+ --attribute-sheet-divider-colour: var(--item-sheet-divider);
+ --attribute-sheet-hint-colour: var(--steel-250);
+ --attribute-sheet-input-colour: var(--item-sheet-input-colour);
+ --attribute-sheet-input-background: var(--item-sheet-input-background);
+ --attribute-sheet-disabled-input-colour: var(--steel-350);
+ --attribute-sheet-toggle-slider-enabled-colour: var(--item-sheet-toggle-slider-enabled-colour);
+ --attribute-sheet-toggle-slider-disabled-colour: var(--item-sheet-toggle-slider-disabled-colour);
+
/* Chip Variables */
--chip-colour: #fff7ed;
--chip-background: #2b3642;
diff --git a/system.json b/system.json
index 6a9bdef..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",
@@ -45,6 +45,10 @@
"description"
],
"filePathFields": {}
+ },
+ "attribute": {
+ "htmlFields": [],
+ "filePathFields": {}
}
}
},
diff --git a/templates/AttributeItemSheet/header.hbs b/templates/AttributeItemSheet/header.hbs
new file mode 100644
index 0000000..1e540e9
--- /dev/null
+++ b/templates/AttributeItemSheet/header.hbs
@@ -0,0 +1,11 @@
+
+
+
diff --git a/templates/AttributeItemSheet/settings.hbs b/templates/AttributeItemSheet/settings.hbs
new file mode 100644
index 0000000..2778d79
--- /dev/null
+++ b/templates/AttributeItemSheet/settings.hbs
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {{ localize "taf.misc.attribute.key.hint" }}
+
+
+
+
+
+
+ {{#if (not system.aboveTheFold)}}
+
+
+
+
+ {{/if}}
+
diff --git a/templates/AttributeItemSheet/value.hbs b/templates/AttributeItemSheet/value.hbs
new file mode 100644
index 0000000..8ad3f14
--- /dev/null
+++ b/templates/AttributeItemSheet/value.hbs
@@ -0,0 +1,37 @@
+
diff --git a/templates/PlayerSheet/item.hbs b/templates/PlayerSheet/tabs/items/item.hbs
similarity index 100%
rename from templates/PlayerSheet/item.hbs
rename to templates/PlayerSheet/tabs/items/item.hbs
diff --git a/templates/PlayerSheet/item-lists.hbs b/templates/PlayerSheet/tabs/items/lists.hbs
similarity index 91%
rename from templates/PlayerSheet/item-lists.hbs
rename to templates/PlayerSheet/tabs/items/lists.hbs
index cd67ee3..a8ae7e2 100644
--- a/templates/PlayerSheet/item-lists.hbs
+++ b/templates/PlayerSheet/tabs/items/lists.hbs
@@ -38,7 +38,7 @@
{{#each itemGroups as | group |}}
-