Merge pull request 'Attribute Item Subtype' (#76) from feature/attribute-items into main

Reviewed-on: #76
This commit is contained in:
Oliver 2026-04-27 02:12:56 +00:00
commit 0347a00632
31 changed files with 1048 additions and 180 deletions

View file

@ -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": [
"<div aria-hidden=\"true\" class=\"icon icon--${1:20}\">",
"\t{{{ $2 }}}",
"</div>"
]
}
}

View file

@ -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."
}
}
}

View file

@ -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,
},
});

View file

@ -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
};

View file

@ -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,

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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,
};
};

View file

@ -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();

View file

@ -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();
});

View file

@ -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);
};
};

105
module/migrations/utils.mjs Normal file
View file

@ -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,
);
};

View file

@ -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,
},
};
};

View file

@ -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`,
});
};

5
module/utils/clamp.mjs Normal file
View file

@ -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));
};

View file

@ -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/)
);
};

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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": {}
}
}
},

View file

@ -0,0 +1,11 @@
<header class="sheet-header bordered">
<input
type="text"
class="large"
name="name"
value="{{item.name}}"
title="{{item.name}}"
{{disabled (not meta.editable)}}
placeholder="{{localize "Name"}}"
>
</header>

View file

@ -0,0 +1,39 @@
<div>
<div class="property">
<label for="{{meta.idp}}-key">
{{ localize "taf.misc.key" }}
</label>
<input
type="text"
id="{{meta.idp}}-key"
name="system.key"
value="{{system.key}}"
>
<p class="hint">
{{ localize "taf.misc.attribute.key.hint" }}
</p>
</div>
<div class="property">
<label for="{{meta.idp}}-aboveTheFold">
{{ localize "taf.misc.attribute.always-visible" }}
</label>
<taf-toggle
id="{{meta.idp}}-aboveTheFold"
name="system.aboveTheFold"
{{checked system.aboveTheFold}}
></taf-toggle>
</div>
{{#if (not system.aboveTheFold)}}
<div class="property">
<label for="{{meta.idp}}-group">
{{ localize "taf.misc.item.group" }}
</label>
<input
type="text"
id="{{meta.idp}}-group"
name="system.group"
value="{{system.group}}"
>
</div>
{{/if}}
</div>

View file

@ -0,0 +1,37 @@
<div class="value-controls">
<label for="{{meta.idp}}-min">
{{ localize "taf.misc.attribute.minimum" }}
</label>
<input
type="number"
id="{{meta.idp}}-min"
name="system.min"
value="{{system.min}}"
{{#if system.isRange}}placeholder="0"{{/if}}
{{disabled (not meta.editable)}}
>
<label for="{{meta.idp}}-value">
{{ localize "taf.misc.attribute.current-value" }}
</label>
<input
type="number"
id="{{meta.idp}}-value"
name="system.value"
value="{{system.value}}"
min="{{system.min}}"
max="{{system.max}}"
{{disabled (not meta.editable)}}
>
<label for="{{meta.idp}}-max">
{{ localize "taf.misc.attribute.maximum" }}
</label>
<input
type="number"
id="{{meta.idp}}-max"
name="system.max"
value="{{system.max}}"
{{disabled (not meta.editable)}}
>
</div>

View file

@ -1,34 +0,0 @@
{{#if hasAttributes}}
<div class="attributes">
{{#each attrs as | attr |}}
<fieldset data-attribute="{{ attr.id }}">
<legend>
{{ attr.name }}
</legend>
<div class="attr-range">
<input
type="number"
class="attr-range__value"
name="{{attr.path}}.value"
value="{{attr.value}}"
aria-label="{{localize "taf.Apps.PlayerSheet.current-value"}}"
data-tooltip="@{{ attr.id }}{{#if attr.isRange}}.value{{/if}}"
>
{{#if attr.isRange}}
<span aria-hidden="true">/</span>
<input
type="number"
class="attr-range__max"
name="{{attr.path}}.max"
value="{{attr.max}}"
aria-label="{{localize "taf.Apps.PlayerSheet.max-value"}}"
data-tooltip="@{{ attr.id }}.max"
>
{{/if}}
</div>
</fieldset>
{{/each}}
</div>
{{else}}
<template />
{{/if}}

View file

@ -0,0 +1,54 @@
{{#if hasAttributes}}
<div class="attributes">
{{#each attrs as | attr |}}
<fieldset
data-item-uuid="{{ attr.uuid }}"
data-foreign-uuid="{{ attr.uuid }}"
>
<legend>
{{ attr.name }}
</legend>
<div class="attr-range">
{{#if attr.system.isRange }}
<input
type="number"
id="{{attr.uuid}}-value"
class="attr-range__value"
data-foreign-name="system.value"
value="{{attr.system.value}}"
min="{{attr.system.min}}"
max="{{attr.system.max}}"
aria-label="{{localize "taf.Apps.PlayerSheet.current-value"}}"
data-tooltip="@{{ attr.system.key }}.value"
>
<span aria-hidden="true">/</span>
<input
type="number"
id="{{attr.uuid}}-max"
class="attr-range__max"
data-foreign-name="system.max"
value="{{attr.system.max}}"
min="{{attr.system.min}}"
aria-label="{{localize "taf.Apps.PlayerSheet.max-value"}}"
data-tooltip="@{{ attr.system.key }}.max"
>
{{else}}
<input
type="number"
id="{{attr.uuid}}-value"
class="attr-range__value"
data-foreign-name="system.value"
value="{{attr.system.value}}"
min="{{attr.system.min}}"
max="{{attr.system.max}}"
aria-label="{{localize "taf.Apps.PlayerSheet.current-value"}}"
data-tooltip="@{{ attr.system.key }}"
>
{{/if}}
</div>
</fieldset>
{{/each}}
</div>
{{else}}
<template />
{{/if}}

View file

@ -0,0 +1,17 @@
<li
class="attribute"
data-item-uuid="{{uuid}}"
>
<div class="title grow">
<span class="name">{{ name }}</span>
</div>
<input
type="number"
id="{{uuid}}-value"
data-foreign-uuid="{{uuid}}"
data-foreign-name="system.value"
value="{{ system.value }}"
min="{{ system.inferredMinimum }}"
max="{{ system.max }}"
>
</li>

View file

@ -0,0 +1,20 @@
<div
class="tab attributes-tab {{ifThen tabActive "active" ""}}"
data-group="primary"
data-tab="attributes"
>
{{#each attrGroups as |group|}}
<section>
<div class="embedded-list-header">
<h3 class="grow">
{{ group.name }}
</h3>
</div>
<ul class="embedded-list two-col">
{{#each group.attrs as |attr|}}
{{> (systemFilePath "templates/PlayerSheet/tabs/attributes/attribute.hbs") attr }}
{{/each}}
</ul>
</section>
{{/each}}
</div>

View file

@ -38,7 +38,7 @@
</section>
{{#each itemGroups as | group |}}
<section>
<div class="item-list-header">
<div class="embedded-list-header">
{{#if @root.meta.editable}}
<button
data-action="createEmbeddedItem"
@ -59,9 +59,9 @@
{{ group.weight }}
</span>
</div>
<ul class="item-list">
<ul class="embedded-list">
{{#each group.items as |item|}}
{{> (systemFilePath "templates/PlayerSheet/item.hbs") item }}
{{> (systemFilePath "templates/PlayerSheet/tabs/items/item.hbs") item }}
{{/each}}
</ul>
</section>