From 41fec09dc0cc28b47e621c71917c42b5c2881f72 Mon Sep 17 00:00:00 2001 From: stanavdb <142786862+stanavdb@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:39:03 +0100 Subject: [PATCH] feat: add support for powers (e.g. surges) and path skills (#124) * feat: add Goal & Power item documents and add support for linked skills to paths * feat(talents): add grant rules to be executed upon obtaining the talent * chore: make grant rules deletable * feat(talents): add functionality to execute grant rules for talents * chore(goal): refactor goal onCompletion.grants to rewards * feat(goals): implement automatic reward granting upon goal completion * feat(actor): add unlocked field to non-core skills * feat(character sheet): Show path linked skills beside the path * feat(character sheet): add power type sections to actions list * fix(multi value select component): fix issue causing selected values to lose labels * fix(item rewards): fix issue causing item goal rewards to grant id-ed items the actor already possesses * fix(talents & goals): fix issue causing a duplicate goal to be added when the granting talent is removed and re-added * feat(powers): make it so by default powers use the skill matching their id * feat(actor): add check to block adding a power whose id matches a power already present on the actor * feat(powers): change none power type label and make untyped powers show up in actions list under "powers" --- eslint.config.mjs | 1 + .../client/data/abstract/client-document.d.ts | 2 + .../foundry/client/data/documents/actor.d.ts | 18 +- .../foundry/common/abstract/data.d.ts | 33 +- .../foundry/common/abstract/fields.d.ts | 14 + .../foundry/common/abstract/validation.d.ts | 85 +++++ src/index.ts | 2 + src/lang/en.json | 158 +++++++-- src/style/components.scss | 64 ++++ src/style/sheets/actor/character.scss | 77 +++-- src/style/sheets/actor/module.scss | 114 ++++--- src/style/sheets/item/module.scss | 20 ++ src/style/sheets/sheet.scss | 7 - src/system.json | 5 +- src/system/api.ts | 70 +++- .../actor/components/actions-list.ts | 54 +++ .../actor/components/character/goals-list.ts | 162 +++------ .../actor/components/character/paths.ts | 18 + .../applications/actor/components/index.ts | 1 + .../applications/actor/components/skill.ts | 123 +++++++ .../actor/components/skills-group.ts | 81 ++--- .../components/document-drop-list.ts | 268 +++++++++++++++ .../components/document-reference-input.ts | 2 +- src/system/applications/components/index.ts | 2 + .../components/multi-value-select.ts | 195 +++++++++++ src/system/applications/item/armor-sheet.ts | 5 - .../{ => ancestry}/advancement-talent-list.ts | 2 +- .../{ => ancestry}/ancestry-bonus-talents.ts | 4 +- .../item/components/ancestry/index.ts | 2 + .../item/components/details-id.ts | 13 +- .../item/components/goal/index.ts | 1 + .../item/components/goal/rewards-list.ts | 142 ++++++++ .../applications/item/components/index.ts | 8 +- .../components/talent/grant-rules-list.ts | 134 ++++++++ .../item/components/talent/index.ts | 3 + .../talent-prerequisite-talent-list.ts | 2 +- .../{ => talent}/talent-prerequisites.ts | 6 +- .../item/dialogs/goal/edit-reward.ts | 173 ++++++++++ .../{ => talent}/edit-bonus-talents-rule.ts | 0 .../item/dialogs/talent/edit-grant-rule.ts | 130 ++++++++ .../{ => talent}/edit-talent-prerequisite.ts | 13 +- src/system/applications/item/goal-sheet.ts | 59 ++++ src/system/applications/item/index.ts | 2 + src/system/applications/item/path-sheet.ts | 15 +- src/system/applications/item/power-sheet.ts | 59 ++++ src/system/applications/item/talent-sheet.ts | 1 + src/system/config.ts | 95 ++++-- src/system/data/actor/character.ts | 6 +- src/system/data/actor/common.ts | 38 ++- src/system/data/fields/collection.ts | 312 ++++++++++++++++++ src/system/data/fields/index.ts | 1 + src/system/data/fields/mapping-field.ts | 20 +- src/system/data/item/goal.ts | 135 ++++++++ src/system/data/item/index.ts | 8 + src/system/data/item/mixins/id.ts | 8 + src/system/data/item/path.ts | 35 +- src/system/data/item/power.ts | 97 ++++++ src/system/data/item/talent.ts | 99 +++++- src/system/documents/actor.ts | 259 ++++++++++++--- src/system/documents/item.ts | 100 ++++++ src/system/types/config.ts | 30 +- src/system/types/cosmere.ts | 7 + src/system/types/item.ts | 71 ---- src/system/types/item/goal.ts | 33 ++ src/system/types/item/index.ts | 2 + src/system/types/item/talent.ts | 96 ++++++ src/system/utils/handlebars/index.ts | 3 + .../adversary/components/skills-group.hbs | 14 +- .../adversary/dialogs/configure-skills.hbs | 2 +- .../character/components/goals-list.hbs | 7 +- .../actors/character/components/paths.hbs | 61 ++-- src/templates/actors/components/skill.hbs | 41 +++ .../actors/components/skills-group.hbs | 39 +-- .../general/components/document-drop-list.hbs | 31 ++ .../general/components/multi-value-select.hbs | 28 ++ src/templates/item/components/details-id.hbs | 4 +- .../item/goal/components/rewards-list.hbs | 60 ++++ .../item/goal/dialogs/edit-reward.hbs | 24 ++ .../item/goal/partials/goal-details-tab.hbs | 16 + .../item/goal/parts/sheet-content.hbs | 9 + .../item/path/partials/path-details-tab.hbs | 22 ++ .../item/path/parts/sheet-content.hbs | 9 + .../item/power/partials/power-details-tab.hbs | 23 ++ .../item/power/parts/sheet-content.hbs | 9 + .../talent/components/grant-rules-list.hbs | 52 +++ .../item/talent/components/prerequisites.hbs | 2 + .../item/talent/dialogs/edit-grant-rule.hbs | 27 ++ .../item/talent/dialogs/edit-prerequisite.hbs | 107 +++--- .../talent/partials/talent-details-tab.hbs | 17 + 89 files changed, 3842 insertions(+), 567 deletions(-) create mode 100644 src/system/applications/actor/components/skill.ts create mode 100644 src/system/applications/components/document-drop-list.ts create mode 100644 src/system/applications/components/multi-value-select.ts rename src/system/applications/item/components/{ => ancestry}/advancement-talent-list.ts (99%) rename src/system/applications/item/components/{ => ancestry}/ancestry-bonus-talents.ts (96%) create mode 100644 src/system/applications/item/components/ancestry/index.ts create mode 100644 src/system/applications/item/components/goal/index.ts create mode 100644 src/system/applications/item/components/goal/rewards-list.ts create mode 100644 src/system/applications/item/components/talent/grant-rules-list.ts create mode 100644 src/system/applications/item/components/talent/index.ts rename src/system/applications/item/components/{ => talent}/talent-prerequisite-talent-list.ts (97%) rename src/system/applications/item/components/{ => talent}/talent-prerequisites.ts (95%) create mode 100644 src/system/applications/item/dialogs/goal/edit-reward.ts rename src/system/applications/item/dialogs/{ => talent}/edit-bonus-talents-rule.ts (100%) create mode 100644 src/system/applications/item/dialogs/talent/edit-grant-rule.ts rename src/system/applications/item/dialogs/{ => talent}/edit-talent-prerequisite.ts (92%) create mode 100644 src/system/applications/item/goal-sheet.ts create mode 100644 src/system/applications/item/power-sheet.ts create mode 100644 src/system/data/fields/collection.ts create mode 100644 src/system/data/item/goal.ts create mode 100644 src/system/data/item/power.ts delete mode 100644 src/system/types/item.ts create mode 100644 src/system/types/item/goal.ts create mode 100644 src/system/types/item/index.ts create mode 100644 src/system/types/item/talent.ts create mode 100644 src/templates/actors/components/skill.hbs create mode 100644 src/templates/general/components/document-drop-list.hbs create mode 100644 src/templates/general/components/multi-value-select.hbs create mode 100644 src/templates/item/goal/components/rewards-list.hbs create mode 100644 src/templates/item/goal/dialogs/edit-reward.hbs create mode 100644 src/templates/item/goal/partials/goal-details-tab.hbs create mode 100644 src/templates/item/goal/parts/sheet-content.hbs create mode 100644 src/templates/item/path/partials/path-details-tab.hbs create mode 100644 src/templates/item/path/parts/sheet-content.hbs create mode 100644 src/templates/item/power/partials/power-details-tab.hbs create mode 100644 src/templates/item/power/parts/sheet-content.hbs create mode 100644 src/templates/item/talent/components/grant-rules-list.hbs create mode 100644 src/templates/item/talent/dialogs/edit-grant-rule.hbs diff --git a/eslint.config.mjs b/eslint.config.mjs index 39c16b80..19514672 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-unused-vars': 'off', + 'no-unexpected-multiline': 'off', }, languageOptions: { parserOptions: { diff --git a/src/declarations/foundry/client/data/abstract/client-document.d.ts b/src/declarations/foundry/client/data/abstract/client-document.d.ts index 581ac116..fe72030b 100644 --- a/src/declarations/foundry/client/data/abstract/client-document.d.ts +++ b/src/declarations/foundry/client/data/abstract/client-document.d.ts @@ -10,6 +10,8 @@ declare function _ClientDocumentMixin< >(base: BaseClass): Mixin; declare class ClientDocument { + readonly uuid: string; + /** * A collection of Application instances which should be re-rendered whenever this document is updated. * The keys of this object are the application ids and the values are Application instances. Each diff --git a/src/declarations/foundry/client/data/documents/actor.d.ts b/src/declarations/foundry/client/data/documents/actor.d.ts index 52e15986..119c9bb3 100644 --- a/src/declarations/foundry/client/data/documents/actor.d.ts +++ b/src/declarations/foundry/client/data/documents/actor.d.ts @@ -77,16 +77,16 @@ declare class Actor< /** * Handle how changes to a Token attribute bar are applied to the Actor. * This allows for game systems to override this behavior and deploy special logic. - * @param {string} attribute The attribute path - * @param {number} value The target attribute value - * @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false) - * @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value - * @returns {Promise} The updated Actor document + * @param attribute The attribute path + * @param value The target attribute value + * @param isDelta Whether the number represents a relative change (true) or an absolute change (false) + * @param isBar Whether the new value is part of an attribute bar, or just a direct value + * @returns The updated Actor document */ public async modifyTokenAttribute( - attribute, - value, - isDelta, - isBar, + attribute: string, + value: number, + isDelta: boolean, + isBar: boolean, ): Promise; } diff --git a/src/declarations/foundry/common/abstract/data.d.ts b/src/declarations/foundry/common/abstract/data.d.ts index 89cb82fb..8b5bc03a 100644 --- a/src/declarations/foundry/common/abstract/data.d.ts +++ b/src/declarations/foundry/common/abstract/data.d.ts @@ -600,6 +600,35 @@ namespace foundry { operation: DatabaseCreateOperation, user: documents.BaseUser, ): Promise; + + /* --- Database Update Operations --- */ + + /** + * Pre-process an update operation for a single Document instance. Pre-operation events only occur for the client + * which requested the operation. + * + * @param changes The candidate changes to the Document + * @param options Additional options which modify the update request + * @param user The User requesting the document update + * @returns A return value of false indicates the update operation should be cancelled. + * @internal + */ + async _preUpdate( + changes: object, + options: object, + user: documents.BaseUser, + ): Promise; + + /** + * Post-process an update operation for a single Document instance. Post-operation events occur for all connected + * clients. + * + * @param changed The differential data that was changed relative to the documents prior values + * @param options Additional options which modify the update request + * @param userId The id of the User requesting the document update + * @internal + */ + _onUpdate(changed: object, options: object, userId: string); } interface DataValidationOptions { @@ -755,7 +784,7 @@ namespace foundry { * @param options Options provided to the model constructor * @returns Migrated and cleaned source data which will be stored to the model instance */ - _initializeSource( + protected _initializeSource( data: object | DataModel, options?: object, ): object; @@ -773,7 +802,7 @@ namespace foundry { * This mirrors the workflow of SchemaField#initialize but with some added functionality. * @param options Options provided to the model constructor */ - _initialize(options?: object); + protected _initialize(options?: object); /** * Reset the state of this data instance back to mirror the contained source data, erasing any changes. diff --git a/src/declarations/foundry/common/abstract/fields.d.ts b/src/declarations/foundry/common/abstract/fields.d.ts index 6d5aca2d..de731549 100644 --- a/src/declarations/foundry/common/abstract/fields.d.ts +++ b/src/declarations/foundry/common/abstract/fields.d.ts @@ -202,12 +202,26 @@ declare namespace foundry { */ initialize(value: any, model: object, options?: object): any; + /** + * Export the current value of the field into a serializable object. + * @param value The initialized value of the field + * @returns An exported representation of the field + */ + toObject(value: any): any; + /** * Recursively traverse a schema and retrieve a field specification by a given path * @param path The field path as an array of strings * @internal */ _getField(path: string[]): DataField; + + /** + * Cast a non-default value to ensure it is the correct type for the field + * @param value The provided non-default value + * @returns The standardized value + */ + protected _cast(value: any): any; } class SchemaField extends DataField { diff --git a/src/declarations/foundry/common/abstract/validation.d.ts b/src/declarations/foundry/common/abstract/validation.d.ts index e52355c4..b9af6e77 100644 --- a/src/declarations/foundry/common/abstract/validation.d.ts +++ b/src/declarations/foundry/common/abstract/validation.d.ts @@ -1,6 +1,91 @@ declare namespace foundry { namespace data { namespace validation { + namespace DataModelValidationFailure { + interface Config { + /** + * The value that failed validation for this field. + */ + invalidValue?: any; + + /** + * The value it was replaced by, if any. + */ + fallback?: any; + + /** + * Whether the value was dropped from some parent collection. + * @default true + */ + dropped?: boolean; + + /** + * The validation error message. + */ + message?: string; + + /** + * Whether this failure was unresolved + * @default false + */ + unresolved?: boolean; + } + + interface ElementValidationFailure { + /** + * Either the element's index or some other identifier for it. + */ + id: string | number; + + /** + * Optionally a user-friendly name for the element. + */ + name?: string; + + /** + * The element's validation failure. + */ + failure: DataModelValidationFailure; + } + } + + class DataModelValidationFailure { + /** + * The value that failed validation for this field. + */ + public invalidValue?: any; + + /** + * The value it was replaced by, if any. + */ + public fallback?: any; + + /** + * Whether the value was dropped from some parent collection. + * @defaultValue true + */ + public dropped?: boolean; + + /** + * The validation error message. + */ + public message?: string; + + /** + * If this field contains other fields that are validated + * as part of its validation, their results are recorded here. + */ + public fields: Record; + + /** + * If this field contains a list of elements that are validated + * as part of its validation, their results are recorded here. + */ + public elements: ElementValidationFailure[]; + + constructor(config?: DataModelValidationFailure.Config); + } + class DataModelValidationError extends Error { constructor(errors: Record); } diff --git a/src/index.ts b/src/index.ts index d0e25954..77a069a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,8 @@ Hooks.once('init', async () => { registerItemSheet(ItemType.Talent, applications.item.TalentItemSheet); registerItemSheet(ItemType.Equipment, applications.item.EquipmentItemSheet); registerItemSheet(ItemType.Weapon, applications.item.WeaponItemSheet); + registerItemSheet(ItemType.Goal, applications.item.GoalItemSheet); + registerItemSheet(ItemType.Power, applications.item.PowerItemSheet); CONFIG.Dice.types.push(dice.PlotDie); CONFIG.Dice.terms.p = dice.PlotDie; diff --git a/src/lang/en.json b/src/lang/en.json index 6a425868..099ae792 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -256,7 +256,7 @@ } }, "Skills": { - "AdjustTooltip": "Left Click to increase [skill]'s Rank.
Right Click to decrease [skill]'s Rank." + "AdjustTooltip": "Left Click to increase {skill}'s Rank.
Right Click to decrease {skill}'s Rank." } } }, @@ -347,6 +347,17 @@ "label": "Connection", "label_plural": "Connections", "desc_placeholder": "Edit this to create a description of the NPC and how they are connected to the character." + }, + "Goal": { + "label": "Goal", + "label_plural": "Goals", + "desc_placeholder": "Edit this to describe the goal." + }, + "Power": { + "label": "Power", + "label_plural": "Powers", + "desc_placeholder": "Edit this to describe the power.", + "New": "New {type}" } }, "Weapon": { @@ -412,6 +423,91 @@ "Hint": "Identifier of the Ancenstry this action belongs to. (e.g. \"human\")" } }, + "Goal": { + "Level": { + "Label": "Progress" + }, + "Reward": { + "Type": { + "Items": "Items", + "SkillRanks": "Skill Ranks", + "Label": "Type" + }, + "Skill": { + "Label": "Skill" + }, + "Ranks": { + "Label": "Ranks" + }, + "Items": { + "Label": "Items" + }, + "Validation": { + "MissingSkillOrRanks": "Must set a Skill and Ranks", + "MissingItems": "Must set Items" + } + } + }, + "Power": { + "Identifier": { + "Hint": "Used to uniquely identify this power. Should be the same as the identifier of the associated skill, unless a custom skill is used." + }, + "CustomSkill": { + "Label": "Use custom skill?", + "Hint": "If checked, configure a custom skill instead of the skill matching this power's identifier." + }, + "Skill": { + "Label": "Skill", + "Hint": "The skill associated with this power. Gaining this power grants the character access to this skill." + }, + "Notification": { + "PowerExists": "A power with identifier \"{identifier}\" already exists on {actor}." + } + }, + "Path": { + "LinkedSkills": { + "Label": "Linked Skills", + "Hint": "These skills are displayed alongside the path in the character sheet." + } + }, + "Talent": { + "Type": { + "Ancestry": "Ancestry", + "Path": "Path", + "Power": "Power" + }, + "Prerequisite": { + "Type": { + "Talent": "Talent", + "Attribute": "Attribute", + "Skill": "Skill", + "Connection": "Connection", + "Level": "Level" + }, + "Mode": { + "AnyOf": "Any of", + "AllOf": "All of" + }, + "Level": { + "Label": "Level" + } + }, + "GrantRule": { + "Label": "On Obtain", + "Hint": "These rules are applied when the talent is obtained.", + "Type": { + "Items": "Grant Items", + "Label": "Type" + }, + "Items": { + "Label": "Items" + } + }, + "Power": { + "Label": "Power Identifier", + "Hint": "Identifier of the power this talent belongs to." + } + }, "Activation": { "Type": { "Action": "Action", @@ -507,7 +603,7 @@ }, "Identifier": { "Label": "Identifier", - "Description": "Used to uniquely identify this [type]. Identifier can only contain letters (a-z), numbers (0-9), dashes (-), and underscores (_)." + "Hint": "Used to uniquely identify this {type}. Identifier can only contain letters (a-z), numbers (0-9), dashes (-), and underscores (_)." }, "Type": "Type", "Injury": { @@ -614,6 +710,19 @@ "ConnectionDescription": "Description", "ConnectionDescriptionPlaceholder": "Connection Description", "DropTalents": "Drop Talents here to add them to the list." + }, + "GrantRules": { + "Description": "Description", + "Create": "New rule", + "Edit": "Edit rule", + "Delete": "Remove rule" + } + }, + "Goal": { + "Reward": { + "Title": "Rewards", + "Create": "New Reward", + "SkillRanksDescriptionValue": "{ranks} ranks in {skill}" } } } @@ -639,24 +748,6 @@ } } }, - "Talent": { - "Type": { - "Ancestry": "Ancestry", - "Path": "Path" - }, - "Prerequisite": { - "Type": { - "Talent": "Talent", - "Attribute": "Attribute", - "Skill": "Skill", - "Connection": "Connection" - }, - "Mode": { - "AnyOf": "Any of", - "AllOf": "All of" - } - } - }, "Currencies": {}, "Combat": { "FastPlayers": "Fast Characters", @@ -837,6 +928,12 @@ "DuplicateLevel": "A rule for level {level} already exists" } }, + "EditGrantRule": { + "Title": "Edit Rule" + }, + "EditGoalReward": { + "Title": "Edit Reward" + }, "ReleaseNotes": { "Title": "Cosmere Roleplaying Game - Release Notes {version}" } @@ -848,6 +945,17 @@ "WrongType": "The dropped Document must be of type {type}", "WrongSubtype": "The dropped {type} must be of type {subtype}" } + }, + "DocumentDropListComponent": { + "Placeholder": "Drop a {type} here to add it to the list", + "Warning": { + "DocumentAlreadyInList": "The dropped {type} is already in the list", + "WrongType": "The dropped Document must be of type {type}", + "WrongSubtype": "The dropped {type} must be of type {subtype}" + } + }, + "MultiValueSelect": { + "DefaultPlaceholder": "Select an option" } }, "GENERIC": { @@ -875,6 +983,8 @@ "Name": "Name", "Level": "Level", "Add": "Add", + "Type": "Type", + "Description": "Description", "Button": { "Roll": "Roll", "Continue": "Continue", @@ -885,7 +995,9 @@ "Update": "Update" }, "Notification": { - "GrazeFocusSpent": "Reduced focus by 1" + "GrazeFocusSpent": "Reduced focus by 1", + "AddedItem": "Added {type} \"{item}\" to {actor}", + "IncreasedSkillRank": "Increased {actor}'s {skill} rank by {amount}" }, "Warning": { "NotImplemented": "Sorry! [action] is not implemented yet", @@ -918,6 +1030,7 @@ "ancestry": "Ancestry", "path": "Path", "connection": "Connection", + "goal": "Goal", "injury": "Injury", "specialty": "Specialty", "loot": "Loot", @@ -926,7 +1039,8 @@ "action": "Action", "talent": "Talent", "equipment": "Equipment", - "weapon": "Weapon" + "weapon": "Weapon", + "power": "Power" } }, "UNITS": { diff --git a/src/style/components.scss b/src/style/components.scss index 416ce0ed..2a766b51 100644 --- a/src/style/components.scss +++ b/src/style/components.scss @@ -78,3 +78,67 @@ app-document-reference-input { font-style: italic; } } + +app-multi-value-select { + display: flex; + + .values { + flex: 1; + display: flex; + flex-wrap: wrap; + + .value { + --background: rgba(0, 0, 0, 0.1); + + display: flex; + align-items: center; + background: var(--background); + padding: 1px 4px; + border: 1px solid var(--color-border-dark-tertiary); + border-radius: 2px; + white-space: nowrap; + margin: 0.12rem; + + .controls { + margin-left: 0.25rem; + } + } + } +} + +app-document-drop-list { + --input-focus-outline-color: var(--color-cool-3); + + display: block; + border: 1px dashed var(--color-dark-4); + padding: 0 0.5rem; + border-radius: 0.3rem; + outline: 1px solid transparent; + transition: outline-color 0.5s; + + ul { + .document { + display: flex; + align-items: center; + + .link { + flex: 1; + display: flex; + justify-content: center; + } + } + } + + p { + text-align: center; + font-size: 10pt; + font-style: italic; + color: var(--color-dark-4); + text-shadow: 0 0 0.2rem black; + } + + &.dragover { + outline: 2px solid var(--input-focus-outline-color); + box-shadow: 0 0 5px var(--color-shadow-primary); + } +} \ No newline at end of file diff --git a/src/style/sheets/actor/character.scss b/src/style/sheets/actor/character.scss index 377b027f..2582aba7 100644 --- a/src/style/sheets/actor/character.scss +++ b/src/style/sheets/actor/character.scss @@ -166,10 +166,9 @@ position: relative; border-radius: 0.3rem; margin-bottom: 1rem; + display: flex; - flex-direction: row; - align-items: center; - padding: 0.2rem 1rem; + flex-direction: column; border: 1px solid #463a47; overflow: hidden; @@ -177,40 +176,61 @@ text-shadow: 0 0 0.5rem black; box-shadow: 0 0 0.5rem black; - cursor: pointer; - - .name { + .details { display: flex; - flex-direction: column; - flex: 1; + flex-direction: row; + align-items: center; + padding: 0.2rem 1rem 0 1rem; z-index: 1; + cursor: pointer; - .label { - font-size: 13pt; + .name { + display: flex; + flex-direction: column; + flex: 1; + z-index: 1; + + .label { + font-size: 13pt; + font-weight: bold; + } + + .item-type { + font-size: 9pt; + opacity: 0.75; + } + } + + .level { + font-size: 14pt; font-weight: bold; + z-index: 1; } - - .item-type { - font-size: 9pt; - opacity: 0.75; + + .name, + .level { + &:hover { + text-shadow: 0 0 8px var(--color-shadow-primary); + } + } + + .controls { + margin-left: 1rem; + z-index: 1; } - } - - .level { - font-size: 14pt; - font-weight: bold; - z-index: 1; - } - .name, - .level { - &:hover { - text-shadow: 0 0 8px var(--color-shadow-primary); + &:not(:has(+ .skill-list)) { + padding-bottom: 0.2rem; } } - .controls { - margin-left: 1rem; + .skill-list { + list-style: none; + margin: 0; + padding: 0; + padding-top: 0.8rem; + padding-bottom: 0.3rem; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, .4) 40%); z-index: 1; } @@ -218,8 +238,9 @@ position: absolute; left: 0; right: 0; - opacity: 0.25; + opacity: 0.2; top: -6rem; + z-index: 0; } &.placeholder { diff --git a/src/style/sheets/actor/module.scss b/src/style/sheets/actor/module.scss index 93a07b87..3d1444db 100644 --- a/src/style/sheets/actor/module.scss +++ b/src/style/sheets/actor/module.scss @@ -164,6 +164,13 @@ } } + .value { + text-align: center; + font-size: 18pt; + font-weight: bold; + margin: 0.25ch; + } + /* --- Tabs --- */ .tab[data-tab] { @@ -933,64 +940,77 @@ flex-direction: column; margin: 0; padding: 0; + } + } - .skill { - display: flex; - flex-direction: row; - align-items: center; + app-adversary-skills-group { + font-size: 9.6pt; + } - &:hover { - background-color: rgba(0, 0, 0, 0.25); - } + app-actor-skill { + display: flex; + flex-direction: row; + align-items: center; + text-shadow: 0 0 0.2rem black; - [data-action] { - cursor: pointer; - } + &:hover { + text-shadow: 0 0 8px var(--color-shadow-primary); + } - .mod { - width: 2rem; - text-align: center; - margin: 0 0.3rem; - } + [data-action] { + cursor: pointer; + } - .name { - flex: 1; - } + .mod { + width: 2rem; + text-align: center; + margin: 0 0.3rem; - .attribute { - width: 3rem; - text-align: center; - margin: 0 0.3rem; - } + .operator { + opacity: .5; + } - .pip-list { - display: flex; - flex-direction: row; - list-style-type: none; - padding: 0; - margin: 0 0.3rem 0 0; - - .pip { - > div { - width: 0.65rem; - height: 0.65rem; - border: 1px solid; - border-radius: 50%; - margin: 0.1rem; - } + .val { + font-weight: bold; + } + } - &.active { - > div { - background-color: white; - } - } + .name { + flex: 1; + } + + .attribute { + width: 3rem; + text-align: center; + margin: 0 0.3rem; + } + + .pip-list { + display: flex; + flex-direction: row; + list-style-type: none; + padding: 0; + margin: 0 0.3rem 0 0; + + .pip { + > div { + width: 0.65rem; + height: 0.65rem; + border: 1px solid var(--color-dark-6); + border-radius: 50%; + margin: 0.1rem; + } + + &.active { + > div { + background-color: white; } } + + &:not(.active) > div { + opacity: .5; + } } } } - - app-adversary-skills-group { - font-size: 9.6pt; - } } diff --git a/src/style/sheets/item/module.scss b/src/style/sheets/item/module.scss index 93d06ca1..4512fdd9 100644 --- a/src/style/sheets/item/module.scss +++ b/src/style/sheets/item/module.scss @@ -485,3 +485,23 @@ app-talent-prerequisite-talent-list { text-shadow: 0 0 0.2rem black; } } + +app-goal-rewards-list { + .col.type { + width: 5rem; + } + + .col.description { + flex: 1; + } +} + +app-talent-grant-rules-list { + .col.type { + width: 5rem; + } + + .col.description { + flex: 1; + } +} \ No newline at end of file diff --git a/src/style/sheets/sheet.scss b/src/style/sheets/sheet.scss index 1569af80..fab5a930 100644 --- a/src/style/sheets/sheet.scss +++ b/src/style/sheets/sheet.scss @@ -28,13 +28,6 @@ } .application.sheet { - .value { - text-align: center; - font-size: 18pt; - font-weight: bold; - margin: 0.25ch; - } - .form-group input::placeholder { opacity: 0.5 !important; } diff --git a/src/system.json b/src/system.json index 36ffc67e..0d3e739b 100644 --- a/src/system.json +++ b/src/system.json @@ -36,7 +36,10 @@ "action": {}, "injury": {}, - "connection": {} + "connection": {}, + "goal": {}, + + "power": {} } }, "packs": [ diff --git a/src/system/api.ts b/src/system/api.ts index af804212..a59bf2a8 100644 --- a/src/system/api.ts +++ b/src/system/api.ts @@ -1,11 +1,77 @@ import { + Skill, EquipmentType, WeaponId, ArmorId, PathType, + PowerType, } from '@system/types/cosmere'; -import { CurrencyConfig } from '@system/types/config'; +import { + CurrencyConfig, + SkillConfig, + PowerTypeConfig, +} from '@system/types/config'; + +interface SkillConfigData extends Omit { + /** + * Unique id for the skill. + */ + id: string; +} + +export function registerSkill(data: SkillConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.skills && !force) + throw new Error('Cannot override existing skill config.'); + + if (force) { + console.warn('Registering skill with force=true.'); + } + + // Add to skills config + CONFIG.COSMERE.skills[data.id as Skill] = { + key: data.id, + label: data.label, + attribute: data.attribute, + core: data.core, + hiddenUntilAcquired: data.hiddenUntilAcquired, + }; + + // Add to attribute's skills list + CONFIG.COSMERE.attributes[data.attribute].skills.push(data.id as Skill); +} + +interface PowerTypeConfigData extends PowerTypeConfig { + /** + * Unique id for the power type. + */ + id: string; +} + +export function registerPowerType(data: PowerTypeConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.power.types && !force) + throw new Error('Cannot override existing power type config.'); + + if (force) { + console.warn('Registering power type with force=true.'); + } + + if (data.id === 'none') { + throw new Error('Cannot register power type with id "none".'); + } + + // Add to power types + CONFIG.COSMERE.power.types[data.id as PowerType] = { + label: data.label, + plural: data.plural, + }; +} interface EquipmentTypeConfigData { id: string; @@ -197,6 +263,8 @@ export function registerCurrency(data: CurrencyConfigData, force = false) { /* --- Default Export --- */ export default { + registerSkill, + registerPowerType, registerEquipmentType, registerWeapon, registerArmor, diff --git a/src/system/applications/actor/components/actions-list.ts b/src/system/applications/actor/components/actions-list.ts index fd6bdd95..01e0630e 100644 --- a/src/system/applications/actor/components/actions-list.ts +++ b/src/system/applications/actor/components/actions-list.ts @@ -3,6 +3,9 @@ import { ItemType, ActivationType, ActionCostType, + ItemConsumeType, + Resource, + PowerType, } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; import { CosmereActor } from '@system/documents'; @@ -292,6 +295,8 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< return [ STATIC_SECTIONS.Weapons, + ...this.preparePowersSections(), + ...paths.map((path) => ({ id: path.system.id, label: game.i18n!.format( @@ -369,6 +374,55 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< ]; } + protected preparePowersSections() { + // Get powers + const powers = this.application.actor.powers; + + // Get list of unique power types + const powerTypes = [...new Set(powers.map((p) => p.system.type))]; + + return powerTypes.map((type) => { + // Get config + const config = CONFIG.COSMERE.power.types[type]; + + return { + id: type, + label: game.i18n!.localize(config.plural), + default: false, + filter: (item: CosmereItem) => + item.isPower() && item.system.type === type, + new: (parent: CosmereActor) => + CosmereItem.create( + { + type: ItemType.Power, + name: game.i18n!.format( + 'COSMERE.Item.Type.Power.New', + { + type: game.i18n!.localize(config.label), + }, + ), + system: { + type, + activation: { + type: ActivationType.Utility, + cost: { + type: ActionCostType.Action, + value: 1, + }, + consume: { + type: ItemConsumeType.Resource, + resource: Resource.Investiture, + value: 1, + }, + }, + }, + }, + { parent }, + ) as Promise, + }; + }); + } + protected async prepareSectionsData( sections: ListSection[], items: CosmereItem[], diff --git a/src/system/applications/actor/components/character/goals-list.ts b/src/system/applications/actor/components/character/goals-list.ts index ba4bdab6..cfa3691f 100644 --- a/src/system/applications/actor/components/character/goals-list.ts +++ b/src/system/applications/actor/components/character/goals-list.ts @@ -1,3 +1,5 @@ +import { ItemType } from '@system/types/cosmere'; +import { GoalItem } from '@system/documents/item'; import { ConstructorOf, MouseButton } from '@system/types/utils'; import { SYSTEM_ID } from '@src/system/constants'; @@ -33,7 +35,7 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< }; /* eslint-enable @typescript-eslint/unbound-method */ - private contextGoalId: number | null = null; + private contextGoalId: string | null = null; private controlsDropdownExpanded = false; private controlsDropdownPosition?: { top: number; right: number }; @@ -49,7 +51,7 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get goal id const goalId = $(event.currentTarget!) .closest('[data-id]') - .data('id') as number; + .data('id') as string; this.contextGoalId = goalId; @@ -81,22 +83,25 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get goal id const goalId = $(event.currentTarget!) .closest('[data-id]') - .data('id') as number; + .data('id') as string | undefined; + if (!goalId) return; - // Get the goals - const goals = this.application.actor.system.goals; + // Get the goal + const goalItem = this.application.actor.items.get(goalId); + if (!goalItem?.isGoal()) return; - // Modify the goal - goals[goalId].level += incrementBool ? 1 : -1; - goals[goalId].level = Math.max(0, Math.min(3, goals[goalId].level)); + // Get the goal's current level + const currentLevel = goalItem.system.level; - // Adjust the rank - await this.application.actor.update( - { - 'system.goals': goals, - }, - { render: false }, - ); + // Calculate the new level + const newLevel = incrementBool + ? Math.min(currentLevel + 1, 3) + : Math.max(currentLevel - 1, 0); + + // Update the goal + await goalItem.update({ + 'system.level': newLevel, + }); // Render await this.render(); @@ -136,8 +141,14 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Ensure context goal id is set if (this.contextGoalId !== null) { - // Edit the goal - this.editGoal(this.contextGoalId); + // Get the goal + const goalItem = this.application.actor.items.get( + this.contextGoalId, + ); + if (!goalItem?.isGoal()) return; + + // Show item sheet + void goalItem.sheet?.render(true); } } @@ -146,23 +157,15 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Ensure context goal id is set if (this.contextGoalId !== null) { - // Get goals - const goals = this.application.actor.system.goals; - - // Update the goals - goals.splice(this.contextGoalId, 1); - - // Update actor - await this.application.actor.update( - { - 'system.goals': goals, - }, - { render: false }, + // Get the goal + const goalItem = this.application.actor.items.get( + this.contextGoalId, ); - } + if (!goalItem?.isGoal()) return; - // Render - await this.render(); + // Delete the goal + await goalItem.delete(); + } } public static async onAddGoal(this: CharacterGoalsListComponent) { @@ -171,33 +174,29 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get the goals const goals = this.application.actor.system.goals; + if (!goals) return; - // Add new goal - goals.push({ - text: game.i18n!.localize( - 'COSMERE.Actor.Sheet.Details.Goals.NewText', - ), - level: 0, - }); - - // Update the actor - await this.application.actor.update( + // Create goal + const goal = (await Item.create( { - 'system.goals': goals, + type: ItemType.Goal, + name: game.i18n!.localize( + 'COSMERE.Actor.Sheet.Details.Goals.NewText', + ), + system: { + level: 0, + }, }, - { render: false }, - ); - - // Render - await this.render(); + { parent: this.application.actor }, + )) as GoalItem; - // Edit goal - this.editGoal(goals.length - 1); + // Show item sheet + void goal.sheet?.render(true); } /* --- Context --- */ - public _prepareContext( + public async _prepareContext( params: never, context: BaseActorSheetRenderContext, ) { @@ -210,10 +209,12 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< return Promise.resolve({ ...context, - goals: this.application.actor.system.goals + goals: this.application.actor.goals .map((goal) => ({ - ...goal, - achieved: goal.level === 3, + id: goal.id, + name: goal.name, + level: goal.system.level, + achieved: goal.system.level === 3, })) .filter((goal) => !hideCompletedGoals || !goal.achieved), @@ -225,59 +226,6 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< }, }); } - - /* --- Helpers --- */ - - private editGoal(index: number) { - // Get goal element - const element = $(this.element!).find(`.goal[data-id="${index}"]`); - - // Get span element - const span = element.find('span.title'); - - // Hide span title - span.addClass('inactive'); - - // Get input element - const input = element.find('input.title'); - - // Show - input.removeClass('inactive'); - - setTimeout(() => { - // Focus input - input.trigger('select'); - - // Add event handler - input.on('focusout', async () => { - // Remove handler - input.off('focusout'); - - // Get the goals - const goals = this.application.actor.system.goals; - - // Modify the goal - goals[index].text = input.val() as string; - - // Update value - await this.application.actor.update({ - 'system.goals': goals, - }); - - // Render - void this.render(); - }); - - input.on('keypress', (event) => { - if (event.which !== 13) return; // Enter key - - event.preventDefault(); - event.stopPropagation(); - - input.trigger('focusout'); - }); - }); - } } // Register diff --git a/src/system/applications/actor/components/character/paths.ts b/src/system/applications/actor/components/character/paths.ts index 85d8b81d..67f92d68 100644 --- a/src/system/applications/actor/components/character/paths.ts +++ b/src/system/applications/actor/components/character/paths.ts @@ -67,6 +67,24 @@ export class CharacterPathsComponent extends HandlebarsApplicationComponent< id: path.id, img: path.img, typeLabel: CONFIG.COSMERE.paths.types[path.system.type].label, + skills: path.system.linkedSkills + .filter( + (skillId) => + this.application.actor.system.skills[skillId] + .unlocked === true, + ) + .map((skillId) => ({ + id: skillId, + label: CONFIG.COSMERE.skills[skillId].label, + attribute: CONFIG.COSMERE.skills[skillId].attribute, + attributeLabel: + CONFIG.COSMERE.attributes[ + CONFIG.COSMERE.skills[skillId].attribute + ].label, + rank: this.application.actor.system.skills[skillId] + .rank, + mod: this.application.actor.system.skills[skillId].mod, + })), level: this.application.actor.system.level.paths[ path.system.id ], diff --git a/src/system/applications/actor/components/index.ts b/src/system/applications/actor/components/index.ts index 0e1e66aa..b5b9a2ba 100644 --- a/src/system/applications/actor/components/index.ts +++ b/src/system/applications/actor/components/index.ts @@ -8,6 +8,7 @@ import './injuries-list'; import './resource'; import './search-bar'; import './skills-group'; +import './skill'; import './character'; import './adversary'; diff --git a/src/system/applications/actor/components/skill.ts b/src/system/applications/actor/components/skill.ts new file mode 100644 index 00000000..3747c42d --- /dev/null +++ b/src/system/applications/actor/components/skill.ts @@ -0,0 +1,123 @@ +import { Skill } from '@system/types/cosmere'; +import { ConstructorOf, MouseButton } from '@system/types/utils'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { BaseActorSheet, BaseActorSheetRenderContext } from '../base'; + +// NOTE: Must use a type instead of an interface to match `AnyObject` type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + /** + * The skill to display + */ + skill: Skill; + + /** + * Whether to display the rank pips + * + * @default true + */ + pips?: boolean; + + /** + * Whether the skill is read-only + * + * @default false + */ + readonly?: boolean; +}; + +export class ActorSkillComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/actors/components/skill.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'roll-skill': this.onRollSkill, + 'adjust-skill-rank': { + handler: this.onAdjustSkillRank, + buttons: [MouseButton.Primary, MouseButton.Secondary], + }, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + public static onRollSkill(this: ActorSkillComponent, event: Event) { + event.preventDefault(); + + const skillId = $(event.currentTarget!) + .closest('[data-id]') + .data('id') as Skill; + void this.application.actor.rollSkill(skillId); + } + + public static async onAdjustSkillRank( + this: ActorSkillComponent, + event: Event, + ) { + event.preventDefault(); + + const incrementBool: boolean = event.type === 'click' ? true : false; + + // Get skill id + const skillId = $(event.currentTarget!) + .closest('[data-id]') + .data('id') as Skill; + + // Modify skill rank + await this.application.actor.modifySkillRank(skillId, incrementBool); + } + + /* --- Accessors --- */ + + public get readonly() { + return this.params?.readonly === true; + } + + public get pips() { + return this.params?.pips !== false; + } + + /* --- Context --- */ + + public _prepareContext( + params: Params, + context: BaseActorSheetRenderContext, + ) { + // Get skill + const skill = this.application.actor.system.skills[params.skill]; + + // Get skill config + const config = CONFIG.COSMERE.skills[params.skill]; + + // Get attribute config + const attributeConfig = CONFIG.COSMERE.attributes[config.attribute]; + + return Promise.resolve({ + ...context, + + skill: { + ...skill, + id: params.skill, + label: config.label, + attribute: config.attribute, + attributeLabel: attributeConfig.labelShort, + }, + + editable: !this.readonly, + pips: this.pips, + }); + } +} + +// Register the component +ActorSkillComponent.register('app-actor-skill'); diff --git a/src/system/applications/actor/components/skills-group.ts b/src/system/applications/actor/components/skills-group.ts index e4ee409c..b6cf7591 100644 --- a/src/system/applications/actor/components/skills-group.ts +++ b/src/system/applications/actor/components/skills-group.ts @@ -1,5 +1,5 @@ import { AttributeGroup, Skill } from '@system/types/cosmere'; -import { ConstructorOf, MouseButton } from '@system/types/utils'; +import { ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; @@ -9,6 +9,13 @@ import { BaseActorSheet, BaseActorSheetRenderContext } from '../base'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type Params = { 'group-id': AttributeGroup; + + /** + * Whether or not to display only core skills. + * + * @default true + */ + core?: boolean; }; export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< @@ -18,48 +25,6 @@ export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< static TEMPLATE = 'systems/cosmere-rpg/templates/actors/components/skills-group.hbs'; - /** - * NOTE: Unbound methods is the standard for defining actions - * within ApplicationV2 - */ - /* eslint-disable @typescript-eslint/unbound-method */ - static readonly ACTIONS = { - 'roll-skill': this.onRollSkill, - 'adjust-skill-rank': { - handler: this.onAdjustSkillRank, - buttons: [MouseButton.Primary, MouseButton.Secondary], - }, - }; - /* eslint-enable @typescript-eslint/unbound-method */ - - /* --- Actions --- */ - - public static onRollSkill(this: ActorSkillsGroupComponent, event: Event) { - event.preventDefault(); - - const skillId = $(event.currentTarget!) - .closest('[data-id]') - .data('id') as Skill; - void this.application.actor.rollSkill(skillId); - } - - public static async onAdjustSkillRank( - this: ActorSkillsGroupComponent, - event: Event, - ) { - event.preventDefault(); - - const incrementBool: boolean = event.type === 'click' ? true : false; - - // Get skill id - const skillId = $(event.currentTarget!) - .closest('[data-id]') - .data('id') as Skill; - - // Modify skill rank - await this.application.actor.modifySkillRank(skillId, incrementBool); - } - /* --- Context --- */ public _prepareContext( @@ -82,14 +47,28 @@ export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< id: params['group-id'], skills: skillIds - .map((skillId) => ({ - id: skillId, - config: CONFIG.COSMERE.skills[skillId], - ...this.application.actor.system.skills[skillId], - active: - !CONFIG.COSMERE.skills[skillId].hiddenUntilAcquired || - this.application.actor.system.skills[skillId].rank >= 1, - })) + .map((skillId) => { + // Get skill + const skill = this.application.actor.system.skills[skillId]; + + // Get config + const config = CONFIG.COSMERE.skills[skillId]; + + // Get attribute config + const attrConfig = + CONFIG.COSMERE.attributes[config.attribute]; + + return { + id: skillId, + config: { + ...config, + attrLabel: attrConfig.labelShort, + }, + ...skill, + active: !config.hiddenUntilAcquired || skill.rank >= 1, + }; + }) + .filter((skill) => params.core === false || skill.config.core) // Filter out non-core skills .sort((a, b) => { const _a = a.config.hiddenUntilAcquired ? 1 : 0; const _b = b.config.hiddenUntilAcquired ? 1 : 0; diff --git a/src/system/applications/components/document-drop-list.ts b/src/system/applications/components/document-drop-list.ts new file mode 100644 index 00000000..0c9027ca --- /dev/null +++ b/src/system/applications/components/document-drop-list.ts @@ -0,0 +1,268 @@ +import { ConstructorOf } from '@system/types/utils'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; + +// Mixins +import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; + +type DocumentType = (typeof CONST.ALL_DOCUMENT_TYPES)[number]; + +// NOTE: Must use type here instead of interface as an interface doesn't match AnyObject type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + name?: string; + + /** + * An array of document UUID values + */ + value?: string[]; + + /** + * The specific type of document that this component should accept (i.e. 'Item') + */ + type?: DocumentType; + + /** + * The specific subtype of document that this component should accept (i.e. 'Weapon') + */ + subtype?: string; + + /** + * Whether the field is read-only + */ + readonly?: boolean; + + /** + * Placeholder text for the input + */ + placeholder?: string; +}; + +export class DocumentDropListComponent extends DragDropComponentMixin( + HandlebarsApplicationComponent< + ConstructorOf, + Params + >, +) { + static FORM_ASSOCIATED = true; + + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/general/components/document-drop-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static ACTIONS = { + 'remove-document': this.onRemoveDocument, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + static DRAG_DROP = [ + { + dropSelector: '*', + }, + ]; + + private _value: string[] = []; + private _name?: string; + + /* --- Accessors --- */ + + public get element(): + | (HTMLElement & { name?: string; value: string[] }) + | undefined { + return super.element as unknown as + | (HTMLElement & { name?: string; value: string[] }) + | undefined; + } + + public get readonly() { + return this.params?.readonly === true; + } + + public get value() { + return this._value; + } + + public set value(value: string[]) { + this._value = value; + + // Set value + this.element!.value = value; + + // Dispatch change event + this.element!.dispatchEvent(new Event('change', { bubbles: true })); + } + + public get name() { + return this._name; + } + + public set name(value: string | undefined) { + this._name = value; + + // Set name + this.element!.name = value; + $(this.element!).attr('name', value ?? ''); + } + + public get placeholder(): string | undefined { + return this.params?.placeholder; + } + + /* --- Actions --- */ + + public static onRemoveDocument( + this: DocumentDropListComponent, + event: Event, + ) { + // Get key + const key = $(event.target!).closest('[data-id]').data('id') as string; + + // Remove document + this.value = this.value.filter((v) => v !== key); + + // Rerender + void this.render(); + } + + /* --- Drag drop --- */ + + protected override _canDragDrop() { + return !this.readonly; + } + + protected override _onDragOver(event: DragEvent) { + if (this.readonly) return; + + $(this.element!).addClass('dragover'); + } + + protected override async _onDrop(event: DragEvent) { + if (this.readonly) return; + + // Remove dragover class + $(this.element!).removeClass('dragover'); + + // Get data + const data = TextEditor.getDragEventData(event) as unknown as { + type: string; + uuid: string; + }; + + // Ensure the document is not already in the list + if (this.value.includes(data.uuid)) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.DocumentAlreadyInList', + { + type: + this.params!.type ?? + game.i18n!.localize('GENERIC.Document'), + }, + ), + ); + } + + // Validate type + if (this.params!.type && data.type !== this.params!.type) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.WrongType', + { + type: this.params!.type, + }, + ), + ); + } + + // Validate subtype + if (this.params!.subtype) { + // Get document + const doc = (await fromUuid(data.uuid)) as unknown as { + type: string; + data: { type: string }; + }; + + if (doc.data.type !== this.params!.subtype) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.WrongSubtype', + { + subtype: this.params!.subtype, + }, + ), + ); + } + } + + // Add document to the list + this.value = [...this.value, data.uuid]; + + // Render + void this.render(); + } + + /* --- Lifecycle --- */ + + protected override _onInitialize(params: Params) { + super._onInitialize(params); + + if (this.params!.value) { + this._value = this.params!.value; + } + } + + public override _onAttachListeners(params: Params) { + super._onAttachListeners(params); + + $(this.element!).on('dragleave', () => { + $(this.element!).removeClass('dragover'); + }); + } + + protected override _onRender(params: Params) { + super._onRender(params); + + // Set name + if (this.params!.name) { + this.name = this.params!.name; + } + + // Set readonly + if (this.params!.readonly) { + $(this.element!).attr('readonly', 'readonly'); + } + } + + /* --- Context --- */ + + public async _prepareContext(params: Params) { + // Look up the documents + const docs = ( + await Promise.all( + this.value.map( + async (uuid) => + (await fromUuid( + uuid, + )) as unknown as ClientDocument | null, + ), + ) + ).filter((v) => !!v); + + return { + ...params, + value: this.value, + documents: docs.map((doc) => ({ + uuid: doc.uuid, + link: doc.toAnchor().outerHTML, + })), + }; + } +} + +// Register the component +DocumentDropListComponent.register('app-document-drop-list'); diff --git a/src/system/applications/components/document-reference-input.ts b/src/system/applications/components/document-reference-input.ts index f2a04a16..628c0b47 100644 --- a/src/system/applications/components/document-reference-input.ts +++ b/src/system/applications/components/document-reference-input.ts @@ -80,7 +80,7 @@ export class DocumentReferenceInputComponent extends DragDropComponentMixin( } public get readonly() { - return this.params?.readonly !== false; + return this.params?.readonly === true; } public get value() { diff --git a/src/system/applications/components/index.ts b/src/system/applications/components/index.ts index 5f47b4cd..d35b85c7 100644 --- a/src/system/applications/components/index.ts +++ b/src/system/applications/components/index.ts @@ -1,3 +1,5 @@ import './id-input'; import './multi-state-toggle'; import './document-reference-input'; +import './multi-value-select'; +import './document-drop-list'; diff --git a/src/system/applications/components/multi-value-select.ts b/src/system/applications/components/multi-value-select.ts new file mode 100644 index 00000000..299d9168 --- /dev/null +++ b/src/system/applications/components/multi-value-select.ts @@ -0,0 +1,195 @@ +import { + ConstructorOf, + AnyObject, + DeepPartial, + EmptyObject, +} from '@system/types/utils'; + +// Component imports +import { + ComponentHandlebarsRenderOptions, + HandlebarsApplicationComponent, +} from '@system/applications/component-system'; + +// NOTE: Must use type here instead of interface as an interface doesn't match AnyObject type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + name?: string; + + /** + * The selected values + */ + value?: string[]; + + /** + * The available options + */ + options?: string[] | Record; + + /** + * Placeholder text for the input + */ + placeholder?: string; + + /** + * Whether the field is read-only + */ + readonly?: boolean; +}; + +export class MultiValueSelectComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static FORM_ASSOCIATED = true; + + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/general/components/multi-value-select.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + remove: this.onRemove, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + private _value: string[] = []; + private _name?: string; + + /* --- Accessors --- */ + + public get element(): + | (HTMLElement & { name?: string; value: string[] }) + | undefined { + return super.element as unknown as + | (HTMLElement & { name?: string; value: string[] }) + | undefined; + } + + public get readonly() { + return this.params?.readonly === true; + } + + public get value() { + return this._value; + } + + public set value(value: string[]) { + this._value = value; + + // Set value + this.element!.value = value; + + // Dispatch change event + this.element!.dispatchEvent(new Event('change', { bubbles: true })); + } + + public get name() { + return this._name; + } + + public set name(value: string | undefined) { + this._name = value; + + // Set name + this.element!.name = value; + $(this.element!).attr('name', value ?? ''); + } + + public get placeholder(): string | undefined { + return this.params?.placeholder; + } + + /* --- Actions --- */ + + public static onRemove(this: MultiValueSelectComponent, event: Event) { + // Get key + const key = $(event.target!).closest('[data-id]').data('id') as string; + + // Remove value + this.value = this.value.filter((value) => value !== key); + + // Rerender + void this.render(); + } + + /* --- Lifecycle --- */ + + protected override _onInitialize() { + if (this.params!.value) { + this._value = this.params!.value ?? []; + } + } + + protected override _onAttachListeners(params: Params) { + super._onAttachListeners(params); + + // Handle select change + $(this.element!) + .find('select') + .on('change', (event) => { + const value = $(event.currentTarget).val() as string; + + // Add value + this.value = [...this.value, value]; + + // Rerender + void this.render(); + }); + } + + protected override _onRender(params: Params) { + super._onRender(params); + + // Set name + if (this.params!.name) { + this.name = this.params!.name; + } + + // Set readonly + if (this.params!.readonly) { + $(this.element!).attr('readonly', 'readonly'); + } + } + + /* --- Context --- */ + + public _prepareContext(params: Params) { + // Default options + params.options ??= []; + + // Prepare options + const options = + foundry.utils.getType(params.options) === 'Object' + ? (foundry.utils.deepClone(params.options) as Record< + string, + string + >) + : (params.options as string[]).reduce( + (acc, key) => ({ ...acc, [key]: key }), + {} as Record, + ); + + // Prepare selected + const selected = this._value.map((key) => ({ + key, + label: options[key], + })); + + // Remove selected from options + this._value.forEach((key) => delete options[key]); + + return Promise.resolve({ + selected, + options, + readonly: this.readonly, + placeholder: this.placeholder, + }); + } +} + +// Register the component +MultiValueSelectComponent.register('app-multi-value-select'); diff --git a/src/system/applications/item/armor-sheet.ts b/src/system/applications/item/armor-sheet.ts index bc959f4b..4965db98 100644 --- a/src/system/applications/item/armor-sheet.ts +++ b/src/system/applications/item/armor-sheet.ts @@ -6,11 +6,6 @@ import { SYSTEM_ID } from '@src/system/constants'; import { BaseItemSheet } from './base'; export class ArmorItemSheet extends BaseItemSheet { - /** - * NOTE: Unbound methods is the standard for defining actions and forms - * within ApplicationV2 - */ - static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { diff --git a/src/system/applications/item/components/advancement-talent-list.ts b/src/system/applications/item/components/ancestry/advancement-talent-list.ts similarity index 99% rename from src/system/applications/item/components/advancement-talent-list.ts rename to src/system/applications/item/components/ancestry/advancement-talent-list.ts index c128096f..ed27a0a9 100644 --- a/src/system/applications/item/components/advancement-talent-list.ts +++ b/src/system/applications/item/components/ancestry/advancement-talent-list.ts @@ -2,7 +2,7 @@ import { AnyObject, ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { AncestrySheet } from '../ancestry-sheet'; +import { AncestrySheet } from '../../ancestry-sheet'; // Mixins import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; diff --git a/src/system/applications/item/components/ancestry-bonus-talents.ts b/src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts similarity index 96% rename from src/system/applications/item/components/ancestry-bonus-talents.ts rename to src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts index 479070c4..2bd3fcab 100644 --- a/src/system/applications/item/components/ancestry-bonus-talents.ts +++ b/src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts @@ -2,11 +2,11 @@ import { BonusTalentsRule } from '@system/data/item/ancestry'; import { AnyObject, ConstructorOf } from '@system/types/utils'; // Dialogs -import { EditBonusTalentsRuleDialog } from '../dialogs/edit-bonus-talents-rule'; +import { EditBonusTalentsRuleDialog } from '../../dialogs/talent/edit-bonus-talents-rule'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { AncestrySheet } from '../ancestry-sheet'; +import { AncestrySheet } from '../../ancestry-sheet'; export class AncestryBonusTalentsComponent extends HandlebarsApplicationComponent< ConstructorOf diff --git a/src/system/applications/item/components/ancestry/index.ts b/src/system/applications/item/components/ancestry/index.ts new file mode 100644 index 00000000..1e5e8ec0 --- /dev/null +++ b/src/system/applications/item/components/ancestry/index.ts @@ -0,0 +1,2 @@ +import './advancement-talent-list'; +import './ancestry-bonus-talents'; diff --git a/src/system/applications/item/components/details-id.ts b/src/system/applications/item/components/details-id.ts index f2b02164..d60e2263 100644 --- a/src/system/applications/item/components/details-id.ts +++ b/src/system/applications/item/components/details-id.ts @@ -16,16 +16,9 @@ export class DetailsIdComponent extends HandlebarsApplicationComponent< return Promise.resolve({ ...context, hasId: this.application.item.hasId(), - note: game - .i18n!.localize('COSMERE.Item.Sheet.Identifier.Description') - .replace( - '[type]', - game - .i18n!.localize( - `TYPES.Item.${this.application.item.type}`, - ) - .toLowerCase(), - ), + type: game + .i18n!.localize(`TYPES.Item.${this.application.item.type}`) + .toLowerCase(), }); } } diff --git a/src/system/applications/item/components/goal/index.ts b/src/system/applications/item/components/goal/index.ts new file mode 100644 index 00000000..b19625c1 --- /dev/null +++ b/src/system/applications/item/components/goal/index.ts @@ -0,0 +1 @@ +import './rewards-list'; diff --git a/src/system/applications/item/components/goal/rewards-list.ts b/src/system/applications/item/components/goal/rewards-list.ts new file mode 100644 index 00000000..32980b45 --- /dev/null +++ b/src/system/applications/item/components/goal/rewards-list.ts @@ -0,0 +1,142 @@ +import { Skill } from '@system/types/cosmere'; +import { Goal } from '@system/types/item'; +import { CosmereItem, GoalItem, PowerItem } from '@system/documents/item'; +import { ConstructorOf } from '@system/types/utils'; + +// Dialogs +import { EditGoalRewardDialog } from '@system/applications/item/dialogs/goal/edit-reward'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { GoalItemSheet } from '@system/applications/item'; + +// NOTE: Must use a type instead of an interface to match `AnyObject` type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + rewards: Collection; + editable?: boolean; +}; + +export class RewardsListComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/item/goal/components/rewards-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'create-reward': this.onCreateReward, + 'edit-reward': this.onEditReward, + 'remove-reward': this.onRemoveReward, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + private static async onCreateReward( + this: RewardsListComponent, + event: Event, + ) { + // Create a new reward + const newReward: Goal.Reward = { + type: Goal.Reward.Type.Items, + items: [], + }; + + // Generate a unique ID + const id = foundry.utils.randomID(); + + // Add the new rule to the item + await this.application.item.update({ + [`system.rewards.${id}`]: newReward, + }); + + // Show the edit dialog + await EditGoalRewardDialog.show(this.application.item, { + _id: id, + ...newReward, + }); + } + + private static async onEditReward( + this: RewardsListComponent, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Get reward + const reward = this.application.item.system.rewards.get(id); + if (!reward) return; + + // Show the edit dialog + await EditGoalRewardDialog.show(this.application.item, { + _id: id, + ...reward, + }); + } + + private static async onRemoveReward( + this: RewardsListComponent, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Remove the reward + await this.application.item.update({ + [`system.rewards.-=${id}`]: null, + }); + } + + /* --- Context --- */ + + public async _prepareContext(params: Params) { + const rewards = await Promise.all( + params.rewards.map(async (reward) => { + if (reward.type !== Goal.Reward.Type.Items) return reward; + + // Look up docs + const docs = await Promise.all( + reward.items.map(async (itemUUID) => { + const doc = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + return { + uuid: doc.uuid, + link: doc.toAnchor().outerHTML, + }; + }), + ); + + return { + ...reward, + items: docs, + }; + }), + ); + + return Promise.resolve({ + ...params, + rewards: rewards.map((reward) => ({ + ...reward, + typeLabel: CONFIG.COSMERE.items.goal.rewards.types[reward.type], + })), + editable: params.editable !== false, + }); + } +} + +// Register the component +RewardsListComponent.register('app-goal-rewards-list'); diff --git a/src/system/applications/item/components/index.ts b/src/system/applications/item/components/index.ts index af51bac1..4260c1d2 100644 --- a/src/system/applications/item/components/index.ts +++ b/src/system/applications/item/components/index.ts @@ -8,7 +8,7 @@ import './details-attack'; import './details-damage'; import './details-modality'; import './properties'; -import './talent-prerequisites'; -import './advancement-talent-list'; -import './ancestry-bonus-talents'; -import './talent-prerequisite-talent-list'; + +import './talent'; +import './ancestry'; +import './goal'; diff --git a/src/system/applications/item/components/talent/grant-rules-list.ts b/src/system/applications/item/components/talent/grant-rules-list.ts new file mode 100644 index 00000000..63a4d9b1 --- /dev/null +++ b/src/system/applications/item/components/talent/grant-rules-list.ts @@ -0,0 +1,134 @@ +import { Talent } from '@system/types/item'; +import { CosmereItem } from '@system/documents/item'; +import { ConstructorOf } from '@system/types/utils'; + +// Dialogs +import { EditTalentGrantRuleDialog } from '../../dialogs/talent/edit-grant-rule'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { TalentItemSheet } from '../../talent-sheet'; +import { BaseItemSheetRenderContext } from '../../base'; + +export class TalentGrantRulesList extends HandlebarsApplicationComponent< + ConstructorOf +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/item/talent/components/grant-rules-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'create-rule': this.onCreateGrantRule, + 'edit-rule': this.onEditGrantRule, + 'delete-rule': this.onDeleteGrantRule, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + private static async onCreateGrantRule(this: TalentGrantRulesList) { + // Create a new rule + const newRule: Talent.GrantRule = { + type: Talent.GrantRule.Type.Items, + items: [], + }; + + // Generate a unique ID + const id = foundry.utils.randomID(); + + // Add the new rule to the item + await this.application.item.update({ + [`system.grantRules.${id}`]: newRule, + }); + + // Show the edit dialog + void EditTalentGrantRuleDialog.show(this.application.item, { + _id: id, + ...newRule, + }); + } + + private static onEditGrantRule(this: TalentGrantRulesList, event: Event) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Get rule + const rule = this.application.item.system.grantRules.get(id); + if (!rule) return; + + // Show the edit dialog + void EditTalentGrantRuleDialog.show(this.application.item, { + _id: id, + ...rule, + }); + } + + private static async onDeleteGrantRule( + this: TalentGrantRulesList, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Remove the rule + await this.application.item.update({ + [`system.grantRules.-=${id}`]: null, + }); + } + + /* --- Context --- */ + + public async _prepareContext( + params: never, + context: BaseItemSheetRenderContext, + ) { + // Get rules + const rules = this.application.item.system.grantRules; + + return { + ...context, + rules: await Promise.all( + rules.map(this.prepareGrantRuleContext.bind(this)), + ), + }; + } + + private async prepareGrantRuleContext(rule: Talent.GrantRule) { + return { + ...rule, + typeLabel: CONFIG.COSMERE.items.talent.grantRules.types[rule.type], + + ...(rule.type === Talent.GrantRule.Type.Items + ? { + items: await Promise.all( + rule.items.map(async (itemUUID) => { + // Look up the doc + const item = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + return { + name: item.name, + uuid: item.uuid, + link: item.toAnchor().outerHTML, + }; + }), + ), + } + : {}), + }; + } +} + +// Register the component +TalentGrantRulesList.register('app-talent-grant-rules-list'); diff --git a/src/system/applications/item/components/talent/index.ts b/src/system/applications/item/components/talent/index.ts new file mode 100644 index 00000000..25762add --- /dev/null +++ b/src/system/applications/item/components/talent/index.ts @@ -0,0 +1,3 @@ +import './talent-prerequisite-talent-list'; +import './talent-prerequisites'; +import './grant-rules-list'; diff --git a/src/system/applications/item/components/talent-prerequisite-talent-list.ts b/src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts similarity index 97% rename from src/system/applications/item/components/talent-prerequisite-talent-list.ts rename to src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts index 1a17f6cb..70858846 100644 --- a/src/system/applications/item/components/talent-prerequisite-talent-list.ts +++ b/src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts @@ -4,7 +4,7 @@ import { ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { EditTalentPrerequisiteDialog } from '../dialogs/edit-talent-prerequisite'; +import { EditTalentPrerequisiteDialog } from '../../dialogs/talent/edit-talent-prerequisite'; // Mixins import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; diff --git a/src/system/applications/item/components/talent-prerequisites.ts b/src/system/applications/item/components/talent/talent-prerequisites.ts similarity index 95% rename from src/system/applications/item/components/talent-prerequisites.ts rename to src/system/applications/item/components/talent/talent-prerequisites.ts index ccb7f090..27575eb8 100644 --- a/src/system/applications/item/components/talent-prerequisites.ts +++ b/src/system/applications/item/components/talent/talent-prerequisites.ts @@ -5,12 +5,12 @@ import { ConstructorOf } from '@system/types/utils'; import { Talent } from '@system/types/item'; // Dialogs -import { EditTalentPrerequisiteDialog } from '../dialogs/edit-talent-prerequisite'; +import { EditTalentPrerequisiteDialog } from '../../dialogs/talent/edit-talent-prerequisite'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { TalentItemSheet } from '../talent-sheet'; -import { BaseItemSheetRenderContext } from '../base'; +import { TalentItemSheet } from '../../talent-sheet'; +import { BaseItemSheetRenderContext } from '../../base'; export class TalentPrerequisitesComponent extends HandlebarsApplicationComponent< ConstructorOf diff --git a/src/system/applications/item/dialogs/goal/edit-reward.ts b/src/system/applications/item/dialogs/goal/edit-reward.ts new file mode 100644 index 00000000..f7dead43 --- /dev/null +++ b/src/system/applications/item/dialogs/goal/edit-reward.ts @@ -0,0 +1,173 @@ +import { Skill } from '@system/types/cosmere'; +import { GoalItem } from '@system/documents/item'; +import { Goal } from '@system/types/item'; +import { AnyObject } from '@system/types/utils'; + +import { CollectionField } from '@system/data/fields'; + +const { ApplicationV2 } = foundry.applications.api; + +import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; + +type RewardData = { + _id: string; +} & Goal.Reward; + +export class EditGoalRewardDialog extends ComponentHandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + title: 'DIALOG.EditGrantRule.Title', + minimizable: false, + resizable: true, + positioned: true, + }, + classes: ['dialog', 'edit-reward'], + tag: 'dialog', + position: { + width: 425, + }, + actions: { + update: this.onUpdateReward, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/item/goal/dialogs/edit-reward.hbs', + forms: { + form: { + handler: this.onFormEvent, + submitOnChange: true, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private constructor( + private goal: GoalItem, + private reward: RewardData, + ) { + super({ + id: `${goal.uuid}.Rewards.${reward._id}`, + window: { + title: 'DIALOG.EditGoalReward.Title', + }, + }); + } + + /* --- Statics --- */ + + public static async show(goal: GoalItem, reward: RewardData) { + const dialog = new this(goal, foundry.utils.deepClone(reward)); + await dialog.render(true); + } + + /* --- Actions --- */ + + private static async onUpdateReward(this: EditGoalRewardDialog) { + // Validate + if ( + this.reward.type === Goal.Reward.Type.SkillRanks && + (this.reward.skill === null || this.reward.ranks === null) + ) { + ui.notifications.error( + 'COSMERE.Item.Goal.Reward.Validation.MissingSkillOrRanks', + ); + return; + } else if ( + this.reward.type === Goal.Reward.Type.Items && + this.reward.items === null + ) { + ui.notifications.error( + 'COSMERE.Item.Goal.Reward.Validation.MissingItems', + ); + return; + } + + // Prepare updates + const updates = + this.reward.type === Goal.Reward.Type.SkillRanks + ? { + type: this.reward.type, + skill: this.reward.skill, + ranks: this.reward.ranks, + } + : { + type: this.reward.type, + items: this.reward.items, + }; + + // Perform updates + await this.goal.update({ + [`system.rewards.${this.reward._id}`]: updates, + }); + + // Close + void this.close(); + } + + /* --- Form --- */ + + protected static onFormEvent( + this: EditGoalRewardDialog, + event: Event, + form: HTMLFormElement, + formData: FormDataExtended, + ) { + if (event instanceof SubmitEvent) return; + + // Get type + this.reward.type = formData.get('type') as Goal.Reward.Type; + + if ( + this.reward.type === Goal.Reward.Type.SkillRanks && + formData.has('skill') + ) { + this.reward.skill = formData.get('skill') as Skill; + this.reward.ranks = parseInt(formData.get('ranks') as string, 10); + } else if ( + this.reward.type === Goal.Reward.Type.Items && + formData.has('items') + ) { + this.reward.items = formData.object.items as unknown as string[]; + } + + // Render + void this.render(true); + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + /* --- Context --- */ + + public _prepareContext() { + return Promise.resolve({ + goal: this.goal, + ...this.reward, + + schema: (this.goal.system.schema.fields.rewards as CollectionField) + .model, + }); + } +} diff --git a/src/system/applications/item/dialogs/edit-bonus-talents-rule.ts b/src/system/applications/item/dialogs/talent/edit-bonus-talents-rule.ts similarity index 100% rename from src/system/applications/item/dialogs/edit-bonus-talents-rule.ts rename to src/system/applications/item/dialogs/talent/edit-bonus-talents-rule.ts diff --git a/src/system/applications/item/dialogs/talent/edit-grant-rule.ts b/src/system/applications/item/dialogs/talent/edit-grant-rule.ts new file mode 100644 index 00000000..f87a2fa8 --- /dev/null +++ b/src/system/applications/item/dialogs/talent/edit-grant-rule.ts @@ -0,0 +1,130 @@ +import { TalentItem } from '@system/documents/item'; +import { Talent } from '@system/types/item'; +import { AnyObject } from '@system/types/utils'; + +import { CollectionField } from '@system/data/fields'; + +const { ApplicationV2 } = foundry.applications.api; + +import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; + +type GrantRuleData = { _id: string } & Talent.GrantRule; + +export class EditTalentGrantRuleDialog extends ComponentHandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + title: 'DIALOG.EditGrantRule.Title', + minimizable: false, + resizable: true, + positioned: true, + }, + classes: ['dialog', 'edit-grant-rule'], + tag: 'dialog', + position: { + width: 350, + }, + actions: { + update: this.onUpdateGrantRule, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/item/talent/dialogs/edit-grant-rule.hbs', + forms: { + form: { + handler: this.onFormEvent, + submitOnChange: true, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private constructor( + private talent: TalentItem, + private rule: GrantRuleData, + ) { + super({ + id: `${talent.uuid}.GrantRule.${rule._id}`, + }); + } + + /* --- Statics --- */ + + public static async show(talent: TalentItem, rule: GrantRuleData) { + const dialog = new this(talent, rule); + await dialog.render(true); + } + + /* --- Actions --- */ + + private static onUpdateGrantRule(this: EditTalentGrantRuleDialog) { + void this.talent.update({ + [`system.grantRules.${this.rule._id}`]: this.rule, + }); + void this.close(); + } + + /* --- Form --- */ + + protected static onFormEvent( + this: EditTalentGrantRuleDialog, + event: Event, + form: HTMLFormElement, + formData: FormDataExtended, + ) { + event.preventDefault(); + + if (event instanceof SubmitEvent) return; + + // Get type + this.rule.type = formData.get('type') as Talent.GrantRule.Type; + + if ( + this.rule.type === Talent.GrantRule.Type.Items && + formData.has('items') + ) { + this.rule.items = formData.object.items as unknown as string[]; + } + + // Render + void this.render(true); + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + /* --- Context --- */ + + public _prepareContext() { + return Promise.resolve({ + editable: true, + talent: this.talent, + schema: ( + this.talent.system.schema.fields + .grantRules as CollectionField + ).model, + ...this.rule, + }); + } +} diff --git a/src/system/applications/item/dialogs/edit-talent-prerequisite.ts b/src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts similarity index 92% rename from src/system/applications/item/dialogs/edit-talent-prerequisite.ts rename to src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts index c5ad5a05..ac88701f 100644 --- a/src/system/applications/item/dialogs/edit-talent-prerequisite.ts +++ b/src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts @@ -124,6 +124,11 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication this.data.talents ??= []; } else if (this.data.type === Talent.Prerequisite.Type.Connection) { this.data.description = formData.get('description') as string; + } else if ( + this.data.type === Talent.Prerequisite.Type.Level && + formData.has('level') + ) { + this.data.level = parseInt(formData.get('level') as string); } // Render @@ -132,7 +137,9 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication /* --- Lifecycle --- */ - protected _onRender(): void { + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + $(this.element).prop('open', true); $(this.element) @@ -146,6 +153,10 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication return Promise.resolve({ editable: true, rootTalent: this.talent, + schema: this.talent.system.schema._getField([ + 'prerequisites', + 'model', + ]), ...this.data, typeSelectOptions: this.talent.system.prerequisiteTypeSelectOptions, diff --git a/src/system/applications/item/goal-sheet.ts b/src/system/applications/item/goal-sheet.ts new file mode 100644 index 00000000..86975238 --- /dev/null +++ b/src/system/applications/item/goal-sheet.ts @@ -0,0 +1,59 @@ +import { GoalItem } from '@system/documents/item'; +import { DeepPartial } from '@system/types/utils'; + +import { SYSTEM_ID } from '@src/system/constants'; + +// Base +import { BaseItemSheet } from './base'; + +export class GoalItemSheet extends BaseItemSheet { + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + classes: [SYSTEM_ID, 'sheet', 'item', 'armor'], + position: { + width: 550, + height: 500, + }, + window: { + resizable: true, + positioned: true, + }, + }, + ); + + static TABS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.TABS), + { + details: { + label: 'COSMERE.Item.Sheet.Tabs.Details', + icon: '', + sortIndex: 15, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + 'sheet-content': { + template: + 'systems/cosmere-rpg/templates/item/goal/parts/sheet-content.hbs', + }, + }, + ); + + get item(): GoalItem { + return super.document; + } + + /* --- Context --- */ + + public async _prepareContext( + options: DeepPartial, + ) { + return { + ...(await super._prepareContext(options)), + }; + } +} diff --git a/src/system/applications/item/index.ts b/src/system/applications/item/index.ts index b810de7c..7563aa79 100644 --- a/src/system/applications/item/index.ts +++ b/src/system/applications/item/index.ts @@ -13,3 +13,5 @@ export * from './action-sheet'; export * from './talent-sheet'; export * from './equipment-sheet'; export * from './weapon-sheet'; +export * from './goal-sheet'; +export * from './power-sheet'; diff --git a/src/system/applications/item/path-sheet.ts b/src/system/applications/item/path-sheet.ts index 3365c558..b3597473 100644 --- a/src/system/applications/item/path-sheet.ts +++ b/src/system/applications/item/path-sheet.ts @@ -37,7 +37,7 @@ export class PathItemSheet extends BaseItemSheet { { 'sheet-content': { template: - 'systems/cosmere-rpg/templates/item/parts/sheet-content.hbs', + 'systems/cosmere-rpg/templates/item/path/parts/sheet-content.hbs', }, }, ); @@ -51,8 +51,21 @@ export class PathItemSheet extends BaseItemSheet { public async _prepareContext( options: DeepPartial, ) { + // Get non-core (locked) skills + const linkedSkillsOptions = Object.entries(CONFIG.COSMERE.skills) + .filter(([key, config]) => !config.core) + .reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ); + return { ...(await super._prepareContext(options)), + + linkedSkillsOptions, }; } } diff --git a/src/system/applications/item/power-sheet.ts b/src/system/applications/item/power-sheet.ts new file mode 100644 index 00000000..4ea6f1d1 --- /dev/null +++ b/src/system/applications/item/power-sheet.ts @@ -0,0 +1,59 @@ +import { PowerItem } from '@system/documents/item'; +import { DeepPartial } from '@system/types/utils'; + +import { SYSTEM_ID } from '@src/system/constants'; + +// Base +import { BaseItemSheet } from './base'; + +export class PowerItemSheet extends BaseItemSheet { + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + classes: [SYSTEM_ID, 'sheet', 'item', 'armor'], + position: { + width: 550, + height: 500, + }, + window: { + resizable: true, + positioned: true, + }, + }, + ); + + static TABS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.TABS), + { + details: { + label: 'COSMERE.Item.Sheet.Tabs.Details', + icon: '', + sortIndex: 15, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + 'sheet-content': { + template: + 'systems/cosmere-rpg/templates/item/power/parts/sheet-content.hbs', + }, + }, + ); + + get item(): PowerItem { + return super.document; + } + + /* --- Context --- */ + + public async _prepareContext( + options: DeepPartial, + ) { + return { + ...(await super._prepareContext(options)), + }; + } +} diff --git a/src/system/applications/item/talent-sheet.ts b/src/system/applications/item/talent-sheet.ts index 251dc2a8..484b2ea1 100644 --- a/src/system/applications/item/talent-sheet.ts +++ b/src/system/applications/item/talent-sheet.ts @@ -83,6 +83,7 @@ export class TalentItemSheet extends BaseItemSheet { ...(await super._prepareContext(options)), isPathTalent: this.item.system.type === Talent.Type.Path, isAncestryTalent: this.item.system.type === Talent.Type.Ancestry, + isPowerTalent: this.item.system.type === Talent.Type.Power, hasModality: this.item.system.modality !== null, }; } diff --git a/src/system/config.ts b/src/system/config.ts index 3bdc3ce5..151c0b2c 100644 --- a/src/system/config.ts +++ b/src/system/config.ts @@ -30,10 +30,11 @@ import { PathType, EquipHand, EquipmentType, + PowerType, } from './types/cosmere'; import { AdvantageMode } from './types/roll'; -import { Talent } from './types/item'; +import { Talent, Goal } from './types/item'; const COSMERE: CosmereRPGConfig = { sizes: { @@ -256,111 +257,111 @@ const COSMERE: CosmereRPGConfig = { key: Skill.Agility, label: 'COSMERE.Actor.Skill.Agility', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Athletics]: { key: Skill.Athletics, label: 'COSMERE.Actor.Skill.Athletics', attribute: Attribute.Strength, - attrLabel: 'COSMERE.Actor.Attribute.Strength.short', + core: true, }, [Skill.HeavyWeapons]: { key: Skill.HeavyWeapons, label: 'COSMERE.Actor.Skill.HeavyWeapons', attribute: Attribute.Strength, - attrLabel: 'COSMERE.Actor.Attribute.Strength.short', + core: true, }, [Skill.LightWeapons]: { key: Skill.LightWeapons, label: 'COSMERE.Actor.Skill.LightWeapons', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Stealth]: { key: Skill.Stealth, label: 'COSMERE.Actor.Skill.Stealth', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Thievery]: { key: Skill.Thievery, label: 'COSMERE.Actor.Skill.Thievery', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Crafting]: { key: Skill.Crafting, label: 'COSMERE.Actor.Skill.Crafting', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Deduction]: { key: Skill.Deduction, label: 'COSMERE.Actor.Skill.Deduction', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Discipline]: { key: Skill.Discipline, label: 'COSMERE.Actor.Skill.Discipline', attribute: Attribute.Willpower, - attrLabel: 'COSMERE.Actor.Attribute.Willpower.short', + core: true, }, [Skill.Intimidation]: { key: Skill.Intimidation, label: 'COSMERE.Actor.Skill.Intimidation', attribute: Attribute.Willpower, - attrLabel: 'COSMERE.Actor.Attribute.Willpower.short', + core: true, }, [Skill.Lore]: { key: Skill.Lore, label: 'COSMERE.Actor.Skill.Lore', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Medicine]: { key: Skill.Medicine, label: 'COSMERE.Actor.Skill.Medicine', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Deception]: { key: Skill.Deception, label: 'COSMERE.Actor.Skill.Deception', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Insight]: { key: Skill.Insight, label: 'COSMERE.Actor.Skill.Insight', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, [Skill.Leadership]: { key: Skill.Leadership, label: 'COSMERE.Actor.Skill.Leadership', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Perception]: { key: Skill.Perception, label: 'COSMERE.Actor.Skill.Perception', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, [Skill.Persuasion]: { key: Skill.Persuasion, label: 'COSMERE.Actor.Skill.Persuasion', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Survival]: { key: Skill.Survival, label: 'COSMERE.Actor.Skill.Survival', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, }, @@ -442,6 +443,16 @@ const COSMERE: CosmereRPGConfig = { desc_placeholder: 'COSMERE.Item.Type.Connection.desc_placeholder', }, + [ItemType.Goal]: { + label: 'COSMERE.Item.Type.Goal.label', + labelPlural: 'COSMERE.Item.Type.Goal.label_plural', + desc_placeholder: 'COSMERE.Item.Type.Goal.desc_placeholder', + }, + [ItemType.Power]: { + label: 'COSMERE.Item.Type.Power.label', + labelPlural: 'COSMERE.Item.Type.Power.label_plural', + desc_placeholder: 'COSMERE.Item.Type.Power.desc_placeholder', + }, }, activation: { types: { @@ -516,31 +527,52 @@ const COSMERE: CosmereRPGConfig = { }, }, }, + goal: { + rewards: { + types: { + [Goal.Reward.Type.Items]: + 'COSMERE.Item.Goal.Reward.Type.Items', + [Goal.Reward.Type.SkillRanks]: + 'COSMERE.Item.Goal.Reward.Type.SkillRanks', + }, + }, + }, talent: { types: { [Talent.Type.Ancestry]: { - label: 'COSMERE.Talent.Type.Ancestry', + label: 'COSMERE.Item.Talent.Type.Ancestry', }, [Talent.Type.Path]: { - label: 'COSMERE.Talent.Type.Path', + label: 'COSMERE.Item.Talent.Type.Path', + }, + [Talent.Type.Power]: { + label: 'COSMERE.Item.Talent.Type.Power', }, }, prerequisite: { types: { [Talent.Prerequisite.Type.Talent]: - 'COSMERE.Talent.Prerequisite.Type.Talent', + 'COSMERE.Item.Talent.Prerequisite.Type.Talent', [Talent.Prerequisite.Type.Attribute]: - 'COSMERE.Talent.Prerequisite.Type.Attribute', + 'COSMERE.Item.Talent.Prerequisite.Type.Attribute', [Talent.Prerequisite.Type.Skill]: - 'COSMERE.Talent.Prerequisite.Type.Skill', + 'COSMERE.Item.Talent.Prerequisite.Type.Skill', [Talent.Prerequisite.Type.Connection]: - 'COSMERE.Talent.Prerequisite.Type.Connection', + 'COSMERE.Item.Talent.Prerequisite.Type.Connection', + [Talent.Prerequisite.Type.Level]: + 'COSMERE.Item.Talent.Prerequisite.Type.Level', }, modes: { [Talent.Prerequisite.Mode.AnyOf]: - 'COSMERE.Talent.Prerequisite.Mode.AnyOf', + 'COSMERE.Item.Talent.Prerequisite.Mode.AnyOf', [Talent.Prerequisite.Mode.AllOf]: - 'COSMERE.Talent.Prerequisite.Mode.AllOf', + 'COSMERE.Item.Talent.Prerequisite.Mode.AllOf', + }, + }, + grantRules: { + types: { + [Talent.GrantRule.Type.Items]: + 'COSMERE.Item.Talent.GrantRule.Type.Items', }, }, }, @@ -723,6 +755,15 @@ const COSMERE: CosmereRPGConfig = { }, }, + power: { + types: { + [PowerType.None]: { + label: 'COSMERE.Item.Type.Power.label', + plural: 'COSMERE.Item.Type.Power.label_plural', + }, + }, + }, + damageTypes: { [DamageType.Energy]: { label: 'COSMERE.DamageTypes.Energy', diff --git a/src/system/data/actor/character.ts b/src/system/data/actor/character.ts index 199cc44c..c01b20a1 100644 --- a/src/system/data/actor/character.ts +++ b/src/system/data/actor/character.ts @@ -23,7 +23,7 @@ export interface CharacterActorData extends CommonActorData { /* --- Goals, Connections, Purpose, and Obstacle --- */ purpose: string; obstacle: string; - goals: GoalData[]; + goals?: GoalData[]; connections: ConnectionData[]; } @@ -76,8 +76,8 @@ export class CharacterActorDataModel extends CommonActorDataModel; skills: Record< Skill, - { attribute: Attribute; rank: number; mod: Derived } + { + attribute: Attribute; + rank: number; + mod: Derived; + + /** + * Derived field describing whether this skill is unlocked or not. + * This field is only present for non-core skills. + * Core skills are always unlocked. + */ + unlocked?: boolean; + } >; injuries: Derived; injuryRollBonus: number; @@ -405,6 +416,18 @@ export class CommonActorDataModel< initial: 0, }), ), + + // Only present for non-core skills + ...(!skills[key].core + ? { + unlocked: + new foundry.data.fields.BooleanField({ + required: true, + nullable: false, + initial: false, + }), + } + : {}), }); return schemas; @@ -556,6 +579,19 @@ export class CommonActorDataModel< this.skills[skill].mod.value = attrValue + rank; }); + // Derive non-core skill unlocks + (Object.keys(this.skills) as Skill[]).forEach((skill) => { + if (CONFIG.COSMERE.skills[skill].core) return; + + // Check if the actor has a power that unlocks this skill + const unlocked = this.parent.powers.some( + (power) => power.system.skill === skill, + ); + + // Set unlocked status + this.skills[skill].unlocked = unlocked; + }); + // Get deflect source, defaulting to armor const source = this.deflect.source ?? DeflectSource.Armor; diff --git a/src/system/data/fields/collection.ts b/src/system/data/fields/collection.ts new file mode 100644 index 00000000..7924c733 --- /dev/null +++ b/src/system/data/fields/collection.ts @@ -0,0 +1,312 @@ +export type CollectionFieldOptions = foundry.data.fields.DataFieldOptions; + +/** + * A collection that is backed by a record object instead of a Map. + * This allows us to persit it properly and update items easily, + * while still having the convenience of a collection. + */ +export class RecordCollection implements Collection { + /** + * NOTE: Must use `any` here as we need the RecordCollection + * to be backing record object itself. This ensures its stored + * properly. + */ + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ + constructor(entries?: [string, T][]) { + if (entries) { + entries.forEach(([key, value]) => { + (this as any)[key] = value; + }); + } + } + + get contents(): T[] { + return Object.entries(this).map(([key, value]) => ({ + ...value, + _id: key, + })); + } + + public find( + condition: (e: T, index: number, collection: Collection) => e is S, + ): S | undefined; + public find( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T | undefined; + public find( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T | undefined { + return Object.entries(this).find(([key, value], index) => + condition({ ...value, _id: key }, index, this), + )?.[1]; + } + + public filter( + condition: (e: T, index: number, collection: Collection) => e is S, + ): S[]; + public filter( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T[]; + public filter( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T[] { + return Object.entries(this) + .filter(([key, value], index) => + condition({ ...value, _id: key }, index, this), + ) + .map(([key, value]) => value); + } + + public has(key: string): boolean { + return key in this; + } + + public get(key: string, options: { strict: true }): T; + public get(key: string, options?: { strict: false }): T | undefined; + public get( + key: string, + options: { strict: boolean } = { strict: false }, + ): T | undefined { + if (!this.has(key)) { + if (options.strict) throw new Error(`key ${key} not found`); + return undefined; + } + return (this as any)[key]; + } + + public getName(name: string, options: { strict: true }): T; + public getName(name: string, options?: { strict: false }): T | undefined; + public getName( + name: string, + options: { strict: boolean } = { strict: false }, + ): T | undefined { + const record = this.contents.find( + (value) => + value && + typeof value === 'object' && + 'name' in value && + value.name === name, + ); + if (!record) { + if (options.strict) throw new Error(`name ${name} not found`); + return undefined; + } + return record; + } + + public map( + transformer: (entity: T, index: number, collection: Collection) => M, + ): M[] { + return Object.entries(this).map(([key, value], index) => + transformer({ ...value, _id: key }, index, this), + ); + } + + public reduce( + evaluator: ( + accumulator: A, + value: T, + index: number, + collection: Collection, + ) => A, + initialValue: A, + ): A { + return Object.entries(this).reduce( + (accumulator, [key, value], index) => + evaluator(accumulator, { ...value, _id: key }, index, this), + initialValue, + ); + } + + public some( + condition: ( + value: T, + index: number, + collection: Collection, + ) => boolean, + ): boolean { + return Object.entries(this).some(([key, value], index) => + condition({ ...value, _id: key }, index, this), + ); + } + + public set(key: string, value: T): this { + (this as any)[key] = value; + return this; + } + + public delete(key: string): boolean { + if (!this.has(key)) return false; + delete (this as any)[key]; + return true; + } + + public clear(): void { + Object.keys(this).forEach((key) => delete (this as any)[key]); + } + + public get size(): number { + return Object.keys(this).length; + } + + public entries(): IterableIterator<[string, T]> { + return Object.entries(this) as unknown as IterableIterator<[string, T]>; + } + + public keys(): IterableIterator { + return Object.keys(this)[Symbol.iterator](); + } + + public values(): IterableIterator { + return Object.keys(this) + .map((key) => ({ + ...this.get(key)!, + _id: key, + })) + [Symbol.iterator](); + } + + public forEach( + callbackfn: (value: T, key: string, map: this) => void, + thisArg?: any, + ): void { + Object.entries(this).forEach(([key, value]) => + callbackfn.call(thisArg, value, key, this), + ); + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } + + // NOTE: This is implicitly readonly as we don't have a way to set it. + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + get [Symbol.toStringTag]() { + return 'RecordCollection'; + } + + public toJSON(): (T extends { toJSON: (...args: any[]) => infer U } + ? U + : T)[] { + return this.contents.map((value) => { + if (value && typeof value === 'object' && 'toJSON' in value) { + return { ...(value as any).toJSON(), _id: (value as any)._id }; + } + return value; + }); + } + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ +} + +export class CollectionField< + ElementField extends + foundry.data.fields.DataField = foundry.data.fields.DataField, +> extends foundry.data.fields.ObjectField { + constructor( + public readonly model: ElementField, + options: CollectionFieldOptions = {}, + ) { + super(options); + } + + protected override _cleanType( + value: RecordCollection, + options?: object, + ) { + Array.from(value.entries()).forEach(([id, v]) => { + value.set(id, this.model.clean(v, options)); + }); + + return value; + } + + protected override _validateType( + value: unknown, + options?: foundry.data.fields.DataFieldValidationOptions, + ): boolean | foundry.data.fields.DataModelValidationFailure | void { + if (!(value instanceof RecordCollection)) + throw new Error('must be a RecordCollection'); + const errors = this._validateValues(value, options); + if (!foundry.utils.isEmpty(errors)) { + // Create validatior failure + const failure = + new foundry.data.validation.DataModelValidationFailure(); + + // Set fields + failure.fields = errors; + + // Throw error + throw new foundry.data.validation.DataModelValidationError(failure); + } + } + + protected _validateValues( + value: RecordCollection, + options?: foundry.data.fields.DataFieldValidationOptions, + ) { + const errors: Record< + string, + foundry.data.validation.DataModelValidationFailure + > = {}; + Array.from(value.entries()).forEach(([id, v]) => { + const error = this.model.validate( + v, + options, + ) as foundry.data.validation.DataModelValidationFailure | null; + if (error) { + errors[id] = error; + } + }); + + return errors; + } + + protected override _cast(value: object) { + const result = + value instanceof RecordCollection + ? value + : foundry.utils.getType(value) === 'Map' + ? new RecordCollection( + Array.from((value as Map).entries()), + ) + : foundry.utils.getType(value) === 'Object' + ? new RecordCollection(Object.entries(value)) + : foundry.utils.getType(value) === 'Array' + ? new RecordCollection( + (value as { _id?: string; id?: string }[]).map( + (v, i) => [v._id ?? v.id ?? i.toString(), v], + ), + ) + : new RecordCollection(); + + return result; + } + + public override getInitialValue() { + return new RecordCollection(); + } + + public override initialize(value: RecordCollection) { + if (!value) return new RecordCollection(); + return foundry.utils.deepClone(value); + } + + public override toObject(value: RecordCollection) { + const result = Array.from(value.entries()).reduce( + (acc, [id, v]) => ({ + ...acc, + [id]: this.model.toObject(v) as unknown, + }), + {}, + ); + return result; + } + + public override _getField(path: string[]): foundry.data.fields.DataField { + if (path.length === 0) return this; + else if (path.length === 1) return this.model; + + path.shift(); + return this.model._getField(path); + } +} diff --git a/src/system/data/fields/index.ts b/src/system/data/fields/index.ts index 8abd3b2e..d8e16209 100644 --- a/src/system/data/fields/index.ts +++ b/src/system/data/fields/index.ts @@ -1,2 +1,3 @@ export * from './derived-value-field'; export * from './mapping-field'; +export * from './collection'; diff --git a/src/system/data/fields/mapping-field.ts b/src/system/data/fields/mapping-field.ts index 21819a76..3065eca6 100644 --- a/src/system/data/fields/mapping-field.ts +++ b/src/system/data/fields/mapping-field.ts @@ -25,8 +25,17 @@ export class MappingField< if (foundry.utils.getType(value) !== 'Object') throw new Error('must be an Object'); const errors = this._validateValues(value, options); - if (!foundry.utils.isEmpty(errors)) - throw new foundry.data.validation.DataModelValidationError(errors); + if (!foundry.utils.isEmpty(errors)) { + // Create validatior failure + const failure = + new foundry.data.validation.DataModelValidationFailure(); + + // Set fields + failure.fields = errors; + + // Throw error + throw new foundry.data.validation.DataModelValidationError(failure); + } } protected _validateValues( @@ -35,10 +44,13 @@ export class MappingField< ) { const errors: Record< string, - foundry.data.fields.DataModelValidationFailure + foundry.data.validation.DataModelValidationFailure > = {}; Object.entries(value).forEach(([key, v]) => { - const error = this.model.validate(v, options); + const error = this.model.validate( + v, + options, + ) as foundry.data.validation.DataModelValidationFailure | null; if (error) errors[key] = error; }); return errors; diff --git a/src/system/data/item/goal.ts b/src/system/data/item/goal.ts new file mode 100644 index 00000000..a7e933fa --- /dev/null +++ b/src/system/data/item/goal.ts @@ -0,0 +1,135 @@ +import { Goal } from '@system/types/item'; +import { CosmereItem } from '@system/documents'; + +import { CollectionField } from '@system/data/fields'; + +// Mixins +import { DataModelMixin } from '../mixins'; +import { IdItemMixin, IdItemData } from './mixins/id'; +import { + DescriptionItemMixin, + DescriptionItemData, +} from './mixins/description'; + +export interface GoalItemData extends IdItemData, DescriptionItemData { + /** + * The progress level of the goal + */ + level: number; + + /** + * The rewards for completing the goal + */ + rewards: Collection; +} + +export class GoalItemDataModel extends DataModelMixin< + GoalItemData, + CosmereItem +>( + IdItemMixin({ + initialFromName: true, + }), + DescriptionItemMixin({ + value: 'COSMERE.Item.Type.Goal.desc_placeholder', + }), +) { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + level: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + integer: true, + min: 0, + max: 3, + initial: 0, + label: 'COSMERE.Item.Goal.Level.Label', + }), + + rewards: new CollectionField( + new foundry.data.fields.SchemaField( + { + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + initial: Goal.Reward.Type.Items, + label: 'COSMERE.Item.Goal.Reward.Type.Label', + choices: CONFIG.COSMERE.items.goal.rewards.types, + }), + + // Skill ranks reward + skill: new foundry.data.fields.StringField({ + required: false, + nullable: true, + blank: false, + initial: null, + label: 'COSMERE.Item.Goal.Reward.Skill.Label', + choices: Object.entries( + CONFIG.COSMERE.skills, + ).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }), + ranks: new foundry.data.fields.NumberField({ + required: false, + nullable: true, + initial: 0, + integer: true, + min: 0, + label: 'COSMERE.Item.Goal.Reward.Ranks.Label', + }), + + // Items reward + items: new foundry.data.fields.ArrayField( + new foundry.data.fields.DocumentUUIDField({ + blank: false, + }), + { + required: false, + nullable: true, + initial: [], + label: 'COSMERE.Item.Goal.Reward.Items.Label', + }, + ), + }, + { + nullable: true, + validate: (value: Goal.Reward) => { + if (value.type === Goal.Reward.Type.SkillRanks) { + if (!('skill' in value)) + throw new Error( + `Field "skill" is required for reward type "${Goal.Reward.Type.SkillRanks}"`, + ); + if (!('ranks' in value)) + throw new Error( + `Field "ranks" is required for reward type "${Goal.Reward.Type.SkillRanks}"`, + ); + } else if (value.type === Goal.Reward.Type.Items) { + if (!('items' in value)) + throw new Error( + `Field "items" is required for reward type "${Goal.Reward.Type.Items}"`, + ); + if (!Array.isArray(value.items)) + throw new Error( + `Field "items" must be an array for reward type "${Goal.Reward.Type.Items}"`, + ); + if ( + value.items.some( + (i) => typeof i !== 'string', + ) + ) + throw new Error( + `Field "items" must be an array of strings for reward type "${Goal.Reward.Type.Items}"`, + ); + } + }, + }, + ), + ), + }); + } +} diff --git a/src/system/data/item/index.ts b/src/system/data/item/index.ts index 6345b7b4..84886377 100644 --- a/src/system/data/item/index.ts +++ b/src/system/data/item/index.ts @@ -17,6 +17,9 @@ import { ActionItemDataModel } from './action'; import { InjuryItemDataModel } from './injury'; import { ConnectionItemDataModel } from './connection'; +import { GoalItemDataModel } from './goal'; + +import { PowerItemDataModel } from './power'; export const config: Record< ItemType, @@ -41,6 +44,9 @@ export const config: Record< [ItemType.Injury]: InjuryItemDataModel, [ItemType.Connection]: ConnectionItemDataModel, + [ItemType.Goal]: GoalItemDataModel, + + [ItemType.Power]: PowerItemDataModel, }; export * from './weapon'; @@ -56,3 +62,5 @@ export * from './action'; export * from './injury'; export * from './connection'; export * from './trait'; +export * from './goal'; +export * from './power'; diff --git a/src/system/data/item/mixins/id.ts b/src/system/data/item/mixins/id.ts index 5221651f..df1ececd 100644 --- a/src/system/data/item/mixins/id.ts +++ b/src/system/data/item/mixins/id.ts @@ -7,6 +7,8 @@ interface IdItemMixinOptions { | Type[] | Record | (() => Type[] | Record); + label?: string; + hint?: string; } export interface IdItemData { @@ -50,6 +52,12 @@ export function IdItemMixin< initial ?? (options.initialFromName ? '' : undefined), choices, + label: + options.label ?? + 'COSMERE.Item.Sheet.Identifier.Label', + hint: + options.hint ?? + 'COSMERE.Item.Sheet.Identifier.Hint', }), }); } diff --git a/src/system/data/item/path.ts b/src/system/data/item/path.ts index ffbfe4c3..ce5333a0 100644 --- a/src/system/data/item/path.ts +++ b/src/system/data/item/path.ts @@ -1,4 +1,4 @@ -import { PathType } from '@system/types/cosmere'; +import { PathType, Skill } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; // Mixins @@ -13,7 +13,13 @@ import { export interface PathItemData extends IdItemData, TypedItemData, - DescriptionItemData {} + DescriptionItemData { + /** + * The non-core skills linked to this path. + * These skills are displayed with the path in the sheet. + */ + linkedSkills: Skill[]; +} export class PathItemDataModel extends DataModelMixin< PathItemData, @@ -38,6 +44,31 @@ export class PathItemDataModel extends DataModelMixin< ) { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), { + linkedSkills: new foundry.data.fields.ArrayField( + new foundry.data.fields.StringField({ + required: true, + nullable: false, + blank: false, + choices: () => + Object.entries(CONFIG.COSMERE.skills) + .filter(([key, skill]) => !skill.core) + .reduce( + (acc, [key, skill]) => ({ + ...acc, + [key]: skill.label, + }), + {}, + ), + }), + { + required: true, + nullable: false, + initial: [], + label: 'COSMERE.Item.Path.LinkedSkills.Label', + hint: 'COSMERE.Item.Path.LinkedSkills.Hint', + }, + ), + // TODO: Advancements }); } diff --git a/src/system/data/item/power.ts b/src/system/data/item/power.ts new file mode 100644 index 00000000..0dfe6b61 --- /dev/null +++ b/src/system/data/item/power.ts @@ -0,0 +1,97 @@ +import { Skill, PowerType } from '@system/types/cosmere'; +import { CosmereItem } from '@system/documents'; + +// Mixins +import { DataModelMixin } from '../mixins'; +import { IdItemMixin, IdItemData } from './mixins/id'; +import { TypedItemMixin, TypedItemData } from './mixins/typed'; +import { + ActivatableItemData, + ActivatableItemMixin, +} from './mixins/activatable'; +import { + DescriptionItemMixin, + DescriptionItemData, +} from './mixins/description'; + +export interface PowerItemData + extends IdItemData, + TypedItemData, + DescriptionItemData { + /** + * Wether to a custom skill is used, or + * the skill is derived from the power's id. + */ + customSkill: boolean; + + /** + * The skill associated with this power. + * This cannot be a core skill. + * If `customSkill` is `false`, the skill with the same id as the power is used. + */ + skill: Skill | null; +} + +export class PowerItemDataModel extends DataModelMixin< + PowerItemData, + CosmereItem +>( + IdItemMixin({ + initialFromName: true, + hint: 'COSMERE.Item.Power.Identifier.Hint', + }), + TypedItemMixin({ + initial: () => Object.keys(CONFIG.COSMERE.power.types)[0], + choices: () => + Object.entries(CONFIG.COSMERE.power.types).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }), + ActivatableItemMixin(), + DescriptionItemMixin({ + value: 'COSMERE.Item.Type.Power.desc_placeholder', + }), +) { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + customSkill: new foundry.data.fields.BooleanField({ + required: true, + initial: false, + label: 'COSMERE.Item.Power.CustomSkill.Label', + hint: 'COSMERE.Item.Power.CustomSkill.Hint', + }), + + skill: new foundry.data.fields.StringField({ + required: true, + nullable: true, + blank: false, + label: 'COSMERE.Item.Power.Skill.Label', + hint: 'COSMERE.Item.Power.Skill.Hint', + initial: null, + choices: () => + Object.entries(CONFIG.COSMERE.skills) + .filter(([key, skill]) => !skill.core) + .reduce( + (acc, [key, skill]) => ({ + ...acc, + [key]: skill.label, + }), + {}, + ), + }), + }); + } + + public prepareDerivedData() { + super.prepareDerivedData(); + + if (!this.customSkill) { + const validId = this.id in CONFIG.COSMERE.skills; + this.skill = validId ? (this.id as Skill) : null; + } + } +} diff --git a/src/system/data/item/talent.ts b/src/system/data/item/talent.ts index a151768a..fc6c7e12 100644 --- a/src/system/data/item/talent.ts +++ b/src/system/data/item/talent.ts @@ -1,7 +1,7 @@ import { Talent } from '@system/types/item'; import { CosmereItem } from '@system/documents'; -import { MappingField } from '@system/data/fields'; +import { MappingField, CollectionField } from '@system/data/fields'; // Mixins import { DataModelMixin } from '../mixins'; @@ -58,6 +58,17 @@ export interface TalentItemData */ hasAncestry?: boolean; + /** + * The id of the Power this Talent belongs to. + */ + power?: string; + /** + * Derived value that indicates whether or not the parent + * Actor has the required power. If no power is defined for this + * Talent, this value will be undefined. + */ + hasPower?: boolean; + prerequisites: Record; readonly prerequisitesArray: ({ id: string } & Talent.Prerequisite)[]; readonly prerequisiteTypeSelectOptions: Record< @@ -75,6 +86,12 @@ export interface TalentItemData * they're just plain strings. */ prerequisitesMet: boolean; + + /** + * Rules that are executed when this talent is + * obtained by an actor. + */ + grantRules: Collection; } export class TalentItemDataModel extends DataModelMixin< @@ -122,6 +139,14 @@ export class TalentItemDataModel extends DataModelMixin< initial: null, }), hasAncestry: new foundry.data.fields.BooleanField(), + power: new foundry.data.fields.StringField({ + required: false, + nullable: true, + initial: null, + label: 'COSMERE.Item.Talent.Power.Label', + hint: 'COSMERE.Item.Talent.Power.Hint', + }), + hasPower: new foundry.data.fields.BooleanField(), prerequisites: new MappingField( new foundry.data.fields.SchemaField( @@ -205,6 +230,13 @@ export class TalentItemDataModel extends DataModelMixin< choices: CONFIG.COSMERE.items.talent.prerequisite.modes, }), + + // Level + level: new foundry.data.fields.NumberField({ + min: 0, + initial: 0, + label: 'COSMERE.Item.Talent.Prerequisite.Level.Label', + }), }, { nullable: true, @@ -252,6 +284,15 @@ export class TalentItemDataModel extends DataModelMixin< 'Field "description" is required for prerequisite rule of type "Connection"', ); break; + case Talent.Prerequisite.Type.Level: + if ( + value.level === undefined || + value.level === null + ) + throw new Error( + 'Field "level" is required for prerequisite rule of type "Level"', + ); + break; default: return false; } @@ -260,6 +301,55 @@ export class TalentItemDataModel extends DataModelMixin< ), ), prerequisitesMet: new foundry.data.fields.BooleanField(), + + grantRules: new CollectionField( + new foundry.data.fields.SchemaField( + { + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + blank: false, + choices: + CONFIG.COSMERE.items.talent.grantRules.types, + label: 'COSMERE.Item.Talent.GrantRule.Type.Label', + }), + + // Items + items: new foundry.data.fields.ArrayField( + new foundry.data.fields.DocumentUUIDField({ + blank: false, + label: 'COSMERE.Item.Talent.GrantRule.Items.Label', + }), + { + required: false, + nullable: true, + initial: null, + }, + ), + }, + { + nullable: true, + validate: (value: Talent.GrantRule) => { + if (value.type === Talent.GrantRule.Type.Items) { + if (!value.items) + throw new Error( + 'Field "items" is required for grant rule of type "Items"', + ); + } else { + throw new Error( + `Invalid grant rule type "${(value as { type: string }).type}"`, + ); + } + }, + }, + ), + { + required: true, + nullable: false, + label: 'COSMERE.Item.Talent.GrantRule.Label', + hint: 'COSMERE.Item.Talent.GrantRule.Hint', + }, + ), }); } @@ -317,6 +407,13 @@ export class TalentItemDataModel extends DataModelMixin< ) ?? false; } + if (this.power) { + this.hasPower = + actor?.items.some( + (item) => item.isPower() && item.id === this.power, + ) ?? false; + } + if (!actor) { this.prerequisitesMet = false; } else { diff --git a/src/system/documents/actor.ts b/src/system/documents/actor.ts index 66921937..dc7fd5f5 100644 --- a/src/system/documents/actor.ts +++ b/src/system/documents/actor.ts @@ -9,6 +9,7 @@ import { Resource, InjuryType, } from '@system/types/cosmere'; +import { Talent } from '@system/types/item'; import { CosmereItem, CosmereItemData, @@ -16,13 +17,19 @@ import { CultureItem, PathItem, TalentItem, + GoalItem, + PowerItem, } from '@system/documents/item'; + import { CommonActorData, CommonActorDataModel, } from '@system/data/actor/common'; import { CharacterActorDataModel } from '@system/data/actor/character'; import { AdversaryActorDataModel } from '@system/data/actor/adversary'; + +import { PowerItemData } from '@system/data/item'; + import { Derived } from '@system/data/fields'; import { SYSTEM_ID } from '../constants'; import { d20Roll, D20Roll, D20RollData, DamageRoll } from '@system/dice'; @@ -93,6 +100,13 @@ export type CosmereActorRollData = skills: Record; }; +// Constants +/** + * Item types of which only a single instance can be + * embedded in an actor. + */ +const SINGLETON_ITEM_TYPES = [ItemType.Ancestry]; + export class CosmereActor< T extends CommonActorDataModel = CommonActorDataModel, SystemType extends CommonActorData = T extends CommonActorDataModel @@ -145,6 +159,14 @@ export class CosmereActor< return this.items.filter((i) => i.isPath()); } + public get goals(): GoalItem[] { + return this.items.filter((i) => i.isGoal()); + } + + public get powers(): PowerItem[] { + return this.items.filter((i) => i.isPower()); + } + /* --- Type Guards --- */ public isCharacter(): this is CharacterActor { @@ -157,6 +179,13 @@ export class CosmereActor< /* --- Lifecycle --- */ + protected override _initialize(options?: object) { + super._initialize(options); + + // Migrate goals + void this.migrateGoals(); + } + public override async _preCreate( data: object, options: object, @@ -186,38 +215,12 @@ export class CosmereActor< data: object[], opertion?: Partial, ): Promise { - const postCreateActions = new Array<() => void>(); - - if (embeddedName === 'Item') { - const itemData = data as CosmereItemData[]; - - // Get the first ancestry item - const ancestryItem = itemData.find( - (d) => d.type === ItemType.Ancestry, - ); - - // Filter out any ancestry items beyond the first - data = itemData.filter( - (d) => d.type !== ItemType.Ancestry || d === ancestryItem, - ); - - // If an ancestry item was present, replace the current (after create) - if (ancestryItem) { - // Get current ancestry item - const currentAncestryItem = this.items.find( - (i) => i.type === ItemType.Ancestry, - ); - - // Remove existing ancestry after create, if present - if (currentAncestryItem) { - postCreateActions.push(() => { - void this.deleteEmbeddedDocuments('Item', [ - currentAncestryItem.id, - ]); - }); - } - } - } + // Pre create actions + if ( + this.preCreateEmbeddedDocuments(embeddedName, data, opertion) === + false + ) + return []; // Perform create const result = await super.createEmbeddedDocuments( @@ -227,7 +230,7 @@ export class CosmereActor< ); // Post create actions - postCreateActions.forEach((func) => func()); + this.postCreateEmbeddedDocuments(embeddedName, result); // Return result return result; @@ -273,6 +276,141 @@ export class CosmereActor< } } + /* --- Handlers --- */ + + protected preCreateEmbeddedDocuments( + embeddedName: string, + data: object[], + opertion?: Partial, + ): boolean | void { + if (embeddedName === 'Item') { + const itemData = data as CosmereItemData[]; + + // Check for singleton items + SINGLETON_ITEM_TYPES.forEach((type) => { + // Get the first item of this type + const item = itemData.find((d) => d.type === type); + + // Filter out any other items of this type + data = item + ? itemData.filter((d) => d.type !== type || d === item) + : itemData; + }); + + // Pre add powers + itemData.forEach((d, i) => { + if (d.type === ItemType.Power) { + if ( + this.preAddPower( + d as CosmereItemData, + ) === false + ) { + itemData.splice(i, 1); + } + } + }); + } + } + + protected preAddPower( + data: CosmereItemData, + ): boolean | void { + // Ensure a power with the same id does not already exist + if ( + this.powers.some( + (i) => i.hasId() && i.system.id === data.system?.id, + ) + ) { + ui.notifications.error( + game.i18n!.format( + 'COSMERE.Item.Power.Notification.PowerExists', + { + actor: this.name, + identifier: data.system!.id, + }, + ), + ); + return false; + } + } + + protected postCreateEmbeddedDocuments( + embeddedName: string, + documents: foundry.abstract.Document[], + ): void { + documents.forEach((doc) => { + if (embeddedName === 'Item') { + const item = doc as CosmereItem; + + if (item.isAncestry()) { + this.onAncestryAdded(item); + } else if (item.isTalent()) { + this.onTalentAdded(item); + } + } + }); + } + + protected onAncestryAdded(item: AncestryItem) { + // Find any other ancestry items + const otherAncestries = this.items.filter( + (i) => i.isAncestry() && i.id !== item.id, + ); + + // Remove other ancestries + otherAncestries.forEach((i) => { + void i.delete(); + }); + } + + protected onTalentAdded(item: TalentItem) { + // Check if the talent has grant rules + if (item.system.grantRules.size > 0) { + // Execute grant rules + item.system.grantRules.forEach((rule) => { + if (rule.type === Talent.GrantRule.Type.Items) { + rule.items.forEach(async (itemUUID) => { + // Get document + const doc = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + // Get id + const id = doc.hasId() ? doc.system.id : null; + + // Ensure the item is not already present + if ( + !id || + this.items.some( + (i) => i.hasId() && i.system.id === id, + ) + ) + return; + + // Add the item to the actor + await this.createEmbeddedDocuments('Item', [ + doc.toObject(), + ]); + + // Notification + ui.notifications.info( + game.i18n!.format( + 'GENERIC.Notification.AddedItem', + { + type: game.i18n!.localize( + `TYPES.Item.${doc.type}`, + ), + item: doc.name, + actor: this.name, + }, + ), + ); + }); + } + }); + } + } + /* --- Functions --- */ public async setMode(modality: string, mode: string) { @@ -507,7 +645,7 @@ export class CosmereActor< attributeOverride ?? CONFIG.COSMERE.skills[skill].attribute; // Get skill rank - const rank = this.system.skills[skill].rank; + const rank = this.system.skills[skill]?.rank ?? 0; // Get attribute value const attrValue = this.getAttributeMod(attributeId); @@ -585,24 +723,40 @@ export class CosmereActor< return item.roll({ ...options, actor: this }); } + /** + * Utility function to modify a skill value + */ + public async modifySkillRank( + skillId: Skill, + change: number, + render?: boolean, + ): Promise; /** * Utility function to increment/decrement a skill value */ public async modifySkillRank( skillId: Skill, - incrementBool = true, + increment: boolean, + render?: boolean, + ): Promise; + public async modifySkillRank( + skillId: Skill, + param1: boolean | number = true, render = true, ) { + const incrementBool = typeof param1 === 'boolean' ? param1 : true; + const changeAmount = typeof param1 === 'number' ? param1 : 1; + const skillpath = `system.skills.${skillId}.rank`; const skill = this.system.skills[skillId]; if (incrementBool) { await this.update( - { [skillpath]: Math.clamp(skill.rank + 1, 0, 5) }, + { [skillpath]: Math.clamp(skill.rank + changeAmount, 0, 5) }, { render }, ); } else { await this.update( - { [skillpath]: Math.clamp(skill.rank - 1, 0, 5) }, + { [skillpath]: Math.clamp(skill.rank - changeAmount, 0, 5) }, { render }, ); } @@ -776,4 +930,35 @@ export class CosmereActor< ) ?? false ); } + + /* --- Helpers --- */ + + /** + * Migrate goals from the system object to individual items. + * + */ + private async migrateGoals() { + if (!this.isCharacter() || !this.system.goals) return; + + const goals = this.system.goals; + + // Remove goals from data + await this.update({ + 'system.goals': null, + }); + + // Create goal items + goals.forEach((goalData) => { + void Item.create( + { + type: ItemType.Goal, + name: goalData.text, + system: { + level: goalData.level, + }, + }, + { parent: this }, + ); + }); + } } diff --git a/src/system/documents/item.ts b/src/system/documents/item.ts index 12afe104..55e2f3d1 100644 --- a/src/system/documents/item.ts +++ b/src/system/documents/item.ts @@ -5,7 +5,12 @@ import { ItemConsumeType, ActivationType, } from '@system/types/cosmere'; +import { Goal } from '@system/types/item'; +import { GoalItemData } from '@system/data/item/goal'; +import { DeepPartial } from '@system/types/utils'; + import { CosmereActor } from './actor'; + import { SYSTEM_ID } from '../constants'; import { Derived } from '@system/data/fields'; @@ -28,6 +33,8 @@ import { TraitItemDataModel, LootItemDataModel, EquipmentItemDataModel, + GoalItemDataModel, + PowerItemDataModel, } from '@system/data/item'; import { ActivatableItemData } from '@system/data/item/mixins/activatable'; @@ -142,6 +149,14 @@ export class CosmereItem< return this.type === ItemType.Equipment; } + public isGoal(): this is GoalItem { + return this.type === ItemType.Goal; + } + + public isPower(): this is PowerItem { + return this.type === ItemType.Power; + } + /* --- Mixin type guards --- */ /** @@ -245,6 +260,89 @@ export class CosmereItem< return activeMode === this.system.id; } + /* --- Lifecycle --- */ + + override _onUpdate(_changes: object, options: object, userId: string) { + super._onUpdate(_changes, options, userId); + + if (game.user?.id !== userId) return; + + if (this.isGoal()) { + const changes: { system?: DeepPartial } = _changes; + + if (changes.system?.level === 3) { + this.handleGoalComplete(); + } + } + } + + /* --- Event handlers --- */ + + protected handleGoalComplete() { + // Ensure the item is a goal + if (!this.isGoal()) return; + + // Ensure actor is set + if (!this.actor) return; + + // Get the rewards + const rewards = this.system.rewards; + + // Handle rewards + rewards.forEach(async (reward) => { + if (reward.type === Goal.Reward.Type.SkillRanks) { + await this.actor!.modifySkillRank(reward.skill, reward.ranks); + + // Notification + ui.notifications.info( + game.i18n!.format( + 'GENERIC.Notification.IncreasedSkillRank', + { + skill: CONFIG.COSMERE.skills[reward.skill].label, + amount: reward.ranks, + actor: this.actor!.name, + }, + ), + ); + } else if (reward.type === Goal.Reward.Type.Items) { + reward.items.forEach(async (itemUUID) => { + // Get the item + const item = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + // Get the id + const id = item.hasId() ? item.system.id : null; + + // Ensure the item is not already embedded + if ( + id && + this.actor!.items.some( + (i) => i.hasId() && i.system.id === id, + ) + ) + return; + + // Add the item to the actor + await this.actor!.createEmbeddedDocuments('Item', [ + item.toObject(), + ]); + + // Notification + ui.notifications.info( + game.i18n!.format('GENERIC.Notification.AddedItem', { + type: game.i18n!.localize( + `TYPES.Item.${item.type}`, + ), + item: item.name, + actor: this.actor!.name, + }), + ); + }); + } + }); + } + /* --- Roll & Usage utilities --- */ /** @@ -1127,3 +1225,5 @@ export type ActionItem = CosmereItem; export type TalentItem = CosmereItem; export type EquipmentItem = CosmereItem; export type WeaponItem = CosmereItem; +export type GoalItem = CosmereItem; +export type PowerItem = CosmereItem; diff --git a/src/system/types/config.ts b/src/system/types/config.ts index 19491101..f93da4b5 100644 --- a/src/system/types/config.ts +++ b/src/system/types/config.ts @@ -29,10 +29,11 @@ import { EquipHand, PathType, EquipmentType, + PowerType, } from './cosmere'; import { AdvantageMode } from './roll'; -import { Talent } from './item'; +import { Talent, Goal } from './item'; export interface SizeConfig { label: string; @@ -73,7 +74,14 @@ export interface SkillConfig { key: string; label: string; attribute: Attribute; - attrLabel: string; + + /** + * Whether the skill is a core skill. + * Core skills are visible in the skill list on the character sheet. + */ + core?: boolean; + + // TODO: Replace hiddenUntilAcquired?: boolean; } @@ -218,6 +226,11 @@ export interface TalentTypeConfig { label: string; } +export interface PowerTypeConfig { + label: string; + plural: string; +} + export interface CosmereRPGConfig { sizes: Record; creatureTypes: Record; @@ -257,12 +270,21 @@ export interface CosmereRPGConfig { types: Record; }; + goal: { + rewards: { + types: Record; + }; + }; + talent: { types: Record; prerequisite: { types: Record; modes: Record; }; + grantRules: { + types: Record; + }; }; }; @@ -293,6 +315,10 @@ export interface CosmereRPGConfig { types: Record; }; + power: { + types: Record; + }; + damageTypes: Record; cultures: Record; diff --git a/src/system/types/cosmere.ts b/src/system/types/cosmere.ts index 3b3696f9..f9279b52 100644 --- a/src/system/types/cosmere.ts +++ b/src/system/types/cosmere.ts @@ -99,6 +99,10 @@ export const enum PathType { Heroic = 'heroic', } +export const enum PowerType { + None = 'none', +} + /** * The categories of weapon available */ @@ -269,6 +273,9 @@ export const enum ItemType { Injury = 'injury', Connection = 'connection', + Goal = 'goal', + + Power = 'power', } export const enum TurnSpeed { diff --git a/src/system/types/item.ts b/src/system/types/item.ts deleted file mode 100644 index 63e09749..00000000 --- a/src/system/types/item.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Attribute, Skill } from './cosmere'; - -export namespace Talent { - export const enum Type { - Ancestry = 'ancestry', - Path = 'path', - } - - export namespace Prerequisite { - export const enum Type { - Talent = 'talent', - Attribute = 'attribute', - Skill = 'skill', - Connection = 'connection', - } - - export const enum Mode { - AnyOf = 'any-of', - AllOf = 'all-of', - } - - export interface TalentRef { - /** - * UUID of the Talent item this prerequisite refers to. - */ - uuid: string; - /** - * The id of the talent - */ - id: string; - /** - * The name of the talent - */ - label: string; - } - } - - interface BasePrerequisite { - type: Type; - } - - export interface ConnectionPrerequisite - extends BasePrerequisite { - description: string; - } - - export interface AttributePrerequisite - extends BasePrerequisite { - attribute: Attribute; - value: number; - } - - export interface SkillPrerequisite - extends BasePrerequisite { - skill: Skill; - rank: number; - } - - export interface TalentPrerequisite - extends BasePrerequisite { - label?: string; - talents: Prerequisite.TalentRef[]; - mode: Prerequisite.Mode; - } - - export type Prerequisite = - | ConnectionPrerequisite - | AttributePrerequisite - | SkillPrerequisite - | TalentPrerequisite; -} diff --git a/src/system/types/item/goal.ts b/src/system/types/item/goal.ts new file mode 100644 index 00000000..93edc094 --- /dev/null +++ b/src/system/types/item/goal.ts @@ -0,0 +1,33 @@ +import { Skill } from '../cosmere'; + +export namespace Reward { + export const enum Type { + SkillRanks = 'skill-ranks', + Items = 'items', + } +} + +interface BaseReward { + type: Type; +} + +export interface SkillRanksReward extends BaseReward { + /** + * The Skill of which ranks are being granted + */ + skill: Skill; + + /** + * The number of ranks being granted + */ + ranks: number; +} + +export interface ItemsReward extends BaseReward { + /** + * The UUIDs of the items being granted + */ + items: string[]; +} + +export type Reward = SkillRanksReward | ItemsReward; diff --git a/src/system/types/item/index.ts b/src/system/types/item/index.ts new file mode 100644 index 00000000..462b07d1 --- /dev/null +++ b/src/system/types/item/index.ts @@ -0,0 +1,2 @@ +export * as Talent from './talent'; +export * as Goal from './goal'; diff --git a/src/system/types/item/talent.ts b/src/system/types/item/talent.ts new file mode 100644 index 00000000..9cde5782 --- /dev/null +++ b/src/system/types/item/talent.ts @@ -0,0 +1,96 @@ +import { Attribute, Skill } from '../cosmere'; + +export const enum Type { + Ancestry = 'ancestry', + Path = 'path', + Power = 'power', +} + +export namespace GrantRule { + export const enum Type { + Items = 'items', + } +} + +export interface BaseGrantRule { + type: Type; +} + +export interface ItemsGrantRule extends BaseGrantRule { + /** + * An array of item UUIDs that are granted by this rule. + */ + items: string[]; +} + +export type GrantRule = ItemsGrantRule; + +export namespace Prerequisite { + export const enum Type { + Talent = 'talent', + Attribute = 'attribute', + Skill = 'skill', + Connection = 'connection', + Level = 'level', + } + + export const enum Mode { + AnyOf = 'any-of', + AllOf = 'all-of', + } + + export interface TalentRef { + /** + * UUID of the Talent item this prerequisite refers to. + */ + uuid: string; + /** + * The id of the talent + */ + id: string; + /** + * The name of the talent + */ + label: string; + } +} + +interface BasePrerequisite { + type: Type; +} + +export interface ConnectionPrerequisite + extends BasePrerequisite { + description: string; +} + +export interface AttributePrerequisite + extends BasePrerequisite { + attribute: Attribute; + value: number; +} + +export interface SkillPrerequisite + extends BasePrerequisite { + skill: Skill; + rank: number; +} + +export interface TalentPrerequisite + extends BasePrerequisite { + label?: string; + talents: Prerequisite.TalentRef[]; + mode: Prerequisite.Mode; +} + +export interface LevelPrerequisite + extends BasePrerequisite { + level: number; +} + +export type Prerequisite = + | ConnectionPrerequisite + | AttributePrerequisite + | SkillPrerequisite + | TalentPrerequisite + | LevelPrerequisite; diff --git a/src/system/utils/handlebars/index.ts b/src/system/utils/handlebars/index.ts index 1126a361..36f97310 100644 --- a/src/system/utils/handlebars/index.ts +++ b/src/system/utils/handlebars/index.ts @@ -487,6 +487,9 @@ export async function preloadHandlebarsTemplates() { 'systems/cosmere-rpg/templates/item/ancestry/partials/ancestry-details-tab.hbs', 'systems/cosmere-rpg/templates/item/talent/partials/talent-details-tab.hbs', 'systems/cosmere-rpg/templates/item/action/partials/action-details-tab.hbs', + 'systems/cosmere-rpg/templates/item/goal/partials/goal-details-tab.hbs', + 'systems/cosmere-rpg/templates/item/power/partials/power-details-tab.hbs', + 'systems/cosmere-rpg/templates/item/path/partials/path-details-tab.hbs', 'systems/cosmere-rpg/templates/combat/combatant.hbs', 'systems/cosmere-rpg/templates/chat/parts/roll-details.hbs', 'systems/cosmere-rpg/templates/chat/parts/chat-card-header.hbs', diff --git a/src/templates/actors/adversary/components/skills-group.hbs b/src/templates/actors/adversary/components/skills-group.hbs index e8df872d..3aaefb2a 100644 --- a/src/templates/actors/adversary/components/skills-group.hbs +++ b/src/templates/actors/adversary/components/skills-group.hbs @@ -5,15 +5,11 @@ {{#if skill.active}}
  • -
    - +{{derived skill.mod }} -
    -
    - {{ localize skill.config.label }} -
    -
    - {{ localize skill.config.attrLabel}} -
    + {{app-actor-skill + skill=skill.id + readonly=(not @root.isEditMode) + pips=false + }}
  • {{/if}} diff --git a/src/templates/actors/adversary/dialogs/configure-skills.hbs b/src/templates/actors/adversary/dialogs/configure-skills.hbs index b8c6e826..b5c2708d 100644 --- a/src/templates/actors/adversary/dialogs/configure-skills.hbs +++ b/src/templates/actors/adversary/dialogs/configure-skills.hbs @@ -1,5 +1,5 @@
    {{#each attributeGroups as |group|}} - {{app-actor-skills-group group-id=group}} + {{app-actor-skills-group group-id=group core=false}} {{/each}}
    \ No newline at end of file diff --git a/src/templates/actors/character/components/goals-list.hbs b/src/templates/actors/character/components/goals-list.hbs index 0c078142..ee1bc785 100644 --- a/src/templates/actors/character/components/goals-list.hbs +++ b/src/templates/actors/character/components/goals-list.hbs @@ -3,12 +3,11 @@ {{localize "COSMERE.Actor.Sheet.Details.Goals.Label"}} - {{#each goals as |goal index|}} -
  • + {{#each goals as |goal|}} +
  • - {{goal.text}} - + {{goal.name}} -
    - {{path.name}} - {{localize path.typeLabel}} Path -
    -
    - {{path.level}} -
    - {{#if @root.isEditMode}} -
    - +
    + {{path.name}} + {{localize path.typeLabel}} Path +
    + +
    - - + {{path.level}} +
    + + {{#if @root.isEditMode}} +
    + + + +
    + {{/if}}
    + + {{!-- Skills --}} + {{#if (gt path.skills.length 0)}} +
      + {{#each path.skills as |skill|}} +
    • + {{app-actor-skill + skill=skill.id + readonly=(not @root.isEditMode) + pips=true + }} +
    • + {{/each}} +
    {{/if}} + {{!-- Background image --}} {{#if img}} -
    - -
    +
    + +
    {{/if}} {{/each}} diff --git a/src/templates/actors/components/skill.hbs b/src/templates/actors/components/skill.hbs new file mode 100644 index 00000000..a2dce179 --- /dev/null +++ b/src/templates/actors/components/skill.hbs @@ -0,0 +1,41 @@ +
    + + + {{derived skill.mod }} +
    +
    + {{ localize skill.label }} +
    +
    + {{ localize skill.attributeLabel}} +
    + +{{#if pips}} + +{{/if}} \ No newline at end of file diff --git a/src/templates/actors/components/skills-group.hbs b/src/templates/actors/components/skills-group.hbs index aad19848..4c8742c8 100644 --- a/src/templates/actors/components/skills-group.hbs +++ b/src/templates/actors/components/skills-group.hbs @@ -2,43 +2,8 @@ {{#each skills as |skill|}} {{#if skill.active}} -
  • -
    - +{{derived skill.mod }} -
    -
    - {{ localize skill.config.label }} -
    -
    - {{ localize skill.config.attrLabel}} -
    - - +
  • + {{app-actor-skill skill=skill.id readonly=(not @root.isEditMode)}}
  • {{/if}} diff --git a/src/templates/general/components/document-drop-list.hbs b/src/templates/general/components/document-drop-list.hbs new file mode 100644 index 00000000..a474d0f7 --- /dev/null +++ b/src/templates/general/components/document-drop-list.hbs @@ -0,0 +1,31 @@ +
      + {{#each documents as |doc index|}} +
    • + +
      + + + +
      + {{/each}} +
    +

    + {{localize + (default + placeholder + (localize + "COMPONENT.DocumentDropListComponent.Placeholder" + type=(localize + (default + (default subtype type) + "GENERIC.Document" + ) + ) + ) + ) + }} +

    \ No newline at end of file diff --git a/src/templates/general/components/multi-value-select.hbs b/src/templates/general/components/multi-value-select.hbs new file mode 100644 index 00000000..b542eb49 --- /dev/null +++ b/src/templates/general/components/multi-value-select.hbs @@ -0,0 +1,28 @@ +{{!-- Selected values --}} +
    + {{#each selected as |value|}} +
    + {{localize value.label}} + + {{#if (not readonly)}} +
    + + + +
    + {{/if}} +
    + {{/each}} +
    + +{{!-- Dropdown --}} +{{#if (not readonly)}} +
    + +
    +{{/if}} \ No newline at end of file diff --git a/src/templates/item/components/details-id.hbs b/src/templates/item/components/details-id.hbs index 42919bba..58326f71 100644 --- a/src/templates/item/components/details-id.hbs +++ b/src/templates/item/components/details-id.hbs @@ -1,12 +1,12 @@ {{#if hasId}}
    - + {{app-id-input name="system.id" value=item.system.id }}

    - {{note}} + {{localize systemFields.id.hint type=type}}

    {{/if}} \ No newline at end of file diff --git a/src/templates/item/goal/components/rewards-list.hbs b/src/templates/item/goal/components/rewards-list.hbs new file mode 100644 index 00000000..7ab45093 --- /dev/null +++ b/src/templates/item/goal/components/rewards-list.hbs @@ -0,0 +1,60 @@ +
      +
    • +
      + {{localize "GENERIC.Type"}} +
      +
      + {{localize "GENERIC.Description"}} +
      +
      + {{#if editable}} +
      + + + + {{/if}} +
      +
    • + + {{#each rewards as |reward index|}} +
    • +
      + {{localize typeLabel}} +
      + +
      + {{#if (eq reward.type "skill-ranks")}} + + {{localize "COSMERE.Item.Sheet.Goal.Reward.SkillRanksDescriptionValue" + skill=(localize (concat "COSMERE.Skill." reward.skill)) + ranks=reward.ranks + }} + + {{else if (eq reward.type "items")}} + {{#each reward.items as |item|}} + {{{item.link}}} + {{#if (not @last)}} + , + {{/if}} + {{/each}} + {{/if}} +
      + +
      + {{#if @root.editable}} + + + + + + + {{/if}} +
    • + {{/each}} +
    \ No newline at end of file diff --git a/src/templates/item/goal/dialogs/edit-reward.hbs b/src/templates/item/goal/dialogs/edit-reward.hbs new file mode 100644 index 00000000..878b80a0 --- /dev/null +++ b/src/templates/item/goal/dialogs/edit-reward.hbs @@ -0,0 +1,24 @@ +
    + {{formGroup schema.fields.type value=type localize=true}} + + {{#if (eq type "skill-ranks")}} + {{formGroup schema.fields.skill value=skill localize=true blank=false}} + {{formGroup schema.fields.ranks value=ranks localize=true}} + {{else if (eq type "items")}} +
    + {{app-document-drop-list + name="items" + value=items + type="Item" + }} +
    + {{/if}} + + {{!-- Submit --}} +
    +
    + +
    +
    \ No newline at end of file diff --git a/src/templates/item/goal/partials/goal-details-tab.hbs b/src/templates/item/goal/partials/goal-details-tab.hbs new file mode 100644 index 00000000..87fd04ba --- /dev/null +++ b/src/templates/item/goal/partials/goal-details-tab.hbs @@ -0,0 +1,16 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
    + {{app-item-details-id}} + {{formGroup @root.systemFields.level + value=@root.item.system.level + localize=true + }} + +
    {{localize "COSMERE.Item.Sheet.Goal.Reward.Title"}}
    + {{app-goal-rewards-list + rewards=@root.item.system.rewards + }} +
    + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/goal/parts/sheet-content.hbs b/src/templates/item/goal/parts/sheet-content.hbs new file mode 100644 index 00000000..91758547 --- /dev/null +++ b/src/templates/item/goal/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
    + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
    + {{> item-description-tab}} + {{> goal-details-tab}} + {{> item-effects-tab}} +
    +
    \ No newline at end of file diff --git a/src/templates/item/path/partials/path-details-tab.hbs b/src/templates/item/path/partials/path-details-tab.hbs new file mode 100644 index 00000000..244ae377 --- /dev/null +++ b/src/templates/item/path/partials/path-details-tab.hbs @@ -0,0 +1,22 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
    + {{app-item-details-id}} + {{app-item-details-type}} + +
    + +
    + {{app-multi-value-select + name="system.linkedSkills" + value=@root.item.system.linkedSkills + options=@root.linkedSkillsOptions + }} +
    +
    +

    + {{localize @root.systemFields.linkedSkills.hint}} +

    +
    + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/path/parts/sheet-content.hbs b/src/templates/item/path/parts/sheet-content.hbs new file mode 100644 index 00000000..c4a7f358 --- /dev/null +++ b/src/templates/item/path/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
    + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
    + {{> item-description-tab}} + {{> path-details-tab}} + {{> item-effects-tab}} +
    +
    \ No newline at end of file diff --git a/src/templates/item/power/partials/power-details-tab.hbs b/src/templates/item/power/partials/power-details-tab.hbs new file mode 100644 index 00000000..062286eb --- /dev/null +++ b/src/templates/item/power/partials/power-details-tab.hbs @@ -0,0 +1,23 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
    + {{app-item-details-id}} + {{app-item-details-type}} + + {{formGroup @root.systemFields.customSkill + value=@root.item.system.customSkill + localize=true + }} + + {{#if @root.item.system.customSkill}} + {{formGroup @root.systemFields.skill + value=@root.item.system.skill + localize=true + blank="GENERIC.None" + }} + {{/if}} + + {{app-item-details-activation}} +
    + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/power/parts/sheet-content.hbs b/src/templates/item/power/parts/sheet-content.hbs new file mode 100644 index 00000000..f3b24683 --- /dev/null +++ b/src/templates/item/power/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
    + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
    + {{> item-description-tab}} + {{> power-details-tab}} + {{> item-effects-tab}} +
    +
    \ No newline at end of file diff --git a/src/templates/item/talent/components/grant-rules-list.hbs b/src/templates/item/talent/components/grant-rules-list.hbs new file mode 100644 index 00000000..b5cf3e55 --- /dev/null +++ b/src/templates/item/talent/components/grant-rules-list.hbs @@ -0,0 +1,52 @@ +
      +
    • +
      + {{localize systemFields.grantRules.model.fields.type.label}} +
      +
      + {{localize "COSMERE.Item.Sheet.Talent.GrantRules.Description"}} +
      +
      + {{#if editable}} +
      + + + + {{/if}} +
      +
    • + + {{#each rules as |rule|}} +
    • +
      + {{localize rule.typeLabel}} +
      +
      + {{#if (eq rule.type "items")}} + {{#each rule.items as |item|}} + {{{item.link}}} + {{#if (not @last)}} + , + {{/if}} + {{/each}} + {{/if}} +
      +
      + {{#if @root.editable}} + + + + + + + {{/if}} +
      +
    • + {{/each}} +
    \ No newline at end of file diff --git a/src/templates/item/talent/components/prerequisites.hbs b/src/templates/item/talent/components/prerequisites.hbs index 9dfcfc33..4cbfb19b 100644 --- a/src/templates/item/talent/components/prerequisites.hbs +++ b/src/templates/item/talent/components/prerequisites.hbs @@ -42,6 +42,8 @@ {{rule.rank}}+ {{else if (eq rule.type "connection")}} {{rule.description}} + {{else if (eq rule.type "level")}} + {{rule.level}}+ {{/if}}
    diff --git a/src/templates/item/talent/dialogs/edit-grant-rule.hbs b/src/templates/item/talent/dialogs/edit-grant-rule.hbs new file mode 100644 index 00000000..6e4b4abe --- /dev/null +++ b/src/templates/item/talent/dialogs/edit-grant-rule.hbs @@ -0,0 +1,27 @@ +
    + {{!-- Type --}} + {{formGroup schema.fields.type + value=type + name="type" + localize=true + }} + + {{!-- Items rule --}} + {{#if (eq type "items")}} +
    + {{app-document-drop-list + name="items" + value=items + type="Item" + }} +
    + {{/if}} + + {{!-- Submit --}} +
    +
    + +
    +
    \ No newline at end of file diff --git a/src/templates/item/talent/dialogs/edit-prerequisite.hbs b/src/templates/item/talent/dialogs/edit-prerequisite.hbs index 6407f714..11d685b2 100644 --- a/src/templates/item/talent/dialogs/edit-prerequisite.hbs +++ b/src/templates/item/talent/dialogs/edit-prerequisite.hbs @@ -2,26 +2,30 @@ {{!-- Type --}}
    - +
    + +
    {{!-- Talent --}} {{#if (eq type "talent")}}
    - {{#if (gt talents.length 1)}} - - {{else}} - - {{/if}} +
    + {{#if (gt talents.length 1)}} + + {{else}} + + {{/if}} +
    @@ -37,20 +41,24 @@ {{#if (eq type "attribute")}}
    - +
    + +
    - +
    + +
    {{/if}} @@ -58,20 +66,24 @@ {{#if (eq type "skill")}}
    - +
    + +
    - +
    + +
    {{/if}} @@ -79,15 +91,26 @@ {{#if (eq type "connection")}}
    - +
    + +
    {{/if}} + {{!-- Level --}} + {{#if (eq type "level")}} + {{formGroup schema.fields.level + value=level + name="level" + localize=true + }} + {{/if}} + {{!-- Submit --}}
    diff --git a/src/templates/item/talent/partials/talent-details-tab.hbs b/src/templates/item/talent/partials/talent-details-tab.hbs index 0cf64bd0..52f2aac3 100644 --- a/src/templates/item/talent/partials/talent-details-tab.hbs +++ b/src/templates/item/talent/partials/talent-details-tab.hbs @@ -45,12 +45,29 @@

    {{/if}} + {{!-- Power talent fields --}} + {{#if @root.isPowerTalent}} + {{formGroup @root.systemFields.power + value=@root.item.system.power + localize=true + blank="GENERIC.None" + }} + {{/if}} + {{!-- Prerequisites --}}
    {{localize "COSMERE.Item.Sheet.Talent.Prerequisites.Label"}}
    {{app-talent-prerequisites}} {{app-item-details-activation}} {{app-item-details-damage}} + + {{!-- Grant rules --}} +
    {{localize @root.systemFields.grantRules.label}}
    + {{app-talent-grant-rules-list}} +

    + {{localize @root.systemFields.grantRules.hint}} +

    + {{app-item-details-modality}}