From b9f7588f95af29262ad4864e3b4056b47dd0059b Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Thu, 13 May 2021 19:59:44 +0200 Subject: [PATCH] Automatically calculate spell price --- src/lang/de.json | 2 +- src/lang/en.json | 2 +- src/module/actor/actor-data.ts | 4 +- src/module/common/time-helpers.ts | 4 + src/module/config.ts | 24 +++- src/module/item/item-data.ts | 31 ++--- src/module/item/item-prepared-data.ts | 4 +- src/module/item/item-sheet.ts | 19 +-- src/module/item/item.ts | 53 +++++++- src/module/migrations.ts | 3 +- src/module/migrations/004.ts | 146 +++++++++++++++++++++ src/scss/components/_description.scss | 4 + src/system.json | 2 +- src/template.json | 5 +- src/templates/sheets/actor/tabs/spells.hbs | 9 +- src/templates/sheets/item/spell-sheet.hbs | 19 ++- 16 files changed, 273 insertions(+), 58 deletions(-) create mode 100644 src/module/common/time-helpers.ts create mode 100644 src/module/migrations/004.ts diff --git a/src/lang/de.json b/src/lang/de.json index 4cc4b57e..12f2708e 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -119,7 +119,7 @@ "DS4.SpellMinimumLevelsWizardAbbr": "Zugangsstufe Zau", "DS4.SpellMinimumLevelsSorcerer": "Zugangsstufe für Schwarzmagier", "DS4.SpellMinimumLevelsSorcererAbbr": "Zugangsstufe Sch", - "DS4.SpellScrollPriceGold": "Schriftrollenpreis (Gold)", + "DS4.SpellPrice": "Preis (Gold)", "DS4.ActorTypeCharacter": "Charakter", "DS4.ActorTypeCreature": "Kreatur", "DS4.AttributeBody": "Körper", diff --git a/src/lang/en.json b/src/lang/en.json index 29cb7eb0..39ad913b 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -119,7 +119,7 @@ "DS4.SpellMinimumLevelsWizardAbbr": "Min lvl WIZ", "DS4.SpellMinimumLevelsSorcerer": "Minimum level for Sorcerers", "DS4.SpellMinimumLevelsSorcererAbbr": "Min lvl SRC", - "DS4.SpellScrollPriceGold": "Scroll Price (Gold)", + "DS4.SpellPrice": "Price (Gold)", "DS4.ActorTypeCharacter": "Character", "DS4.ActorTypeCreature": "Creature", "DS4.AttributeBody": "Body", diff --git a/src/module/actor/actor-data.ts b/src/module/actor/actor-data.ts index ef8ead13..c0684899 100644 --- a/src/module/actor/actor-data.ts +++ b/src/module/actor/actor-data.ts @@ -111,6 +111,6 @@ export interface DS4CreatureDataDataBaseInfo { description: string; } -type CreatureType = "animal" | "construct" | "humanoid" | "magicalEntity" | "plantBeing" | "undead"; +type CreatureType = keyof typeof DS4.i18n.creatureTypes; -type SizeCategory = "tiny" | "small" | "normal" | "large" | "huge" | "colossal"; +type SizeCategory = keyof typeof DS4.i18n.creatureSizeCategories; diff --git a/src/module/common/time-helpers.ts b/src/module/common/time-helpers.ts new file mode 100644 index 00000000..8ed036cc --- /dev/null +++ b/src/module/common/time-helpers.ts @@ -0,0 +1,4 @@ +export const secondsPerRound = 5; +export const secondsPerMinute = 60; +export const minutesPerHour = 60; +export const hoursPerDay = 24; diff --git a/src/module/config.ts b/src/module/config.ts index 8b21668c..b359f20b 100644 --- a/src/module/config.ts +++ b/src/module/config.ts @@ -261,24 +261,44 @@ export const DS4 = { }, /** - * Define translations for available distance units + * Define translations for available duration units */ temporalUnits: { rounds: "DS4.UnitRounds", minutes: "DS4.UnitMinutes", hours: "DS4.UnitHours", days: "DS4.UnitDays", + }, + + /** + * Define translations for available duration units including "custom" + */ + customTemporalUnits: { + rounds: "DS4.UnitRounds", + minutes: "DS4.UnitMinutes", + hours: "DS4.UnitHours", + days: "DS4.UnitDays", custom: "DS4.UnitCustom", }, /** - * Define abbreviations for available units + * Define abbreviations for available duration units */ temporalUnitsAbbr: { rounds: "DS4.UnitRoundsAbbr", minutes: "DS4.UnitMinutesAbbr", hours: "DS4.UnitHoursAbbr", days: "DS4.UnitDaysAbbr", + }, + + /** + * Define abbreviations for available duration units including "custom" + */ + customTemporalUnitsAbbr: { + rounds: "DS4.UnitRoundsAbbr", + minutes: "DS4.UnitMinutesAbbr", + hours: "DS4.UnitHoursAbbr", + days: "DS4.UnitDaysAbbr", custom: "DS4.UnitCustomAbbr", }, diff --git a/src/module/item/item-data.ts b/src/module/item/item-data.ts index 8e90673c..c5038cc6 100644 --- a/src/module/item/item-data.ts +++ b/src/module/item/item-data.ts @@ -40,7 +40,7 @@ interface DS4ItemDataDataBase { interface DS4ItemDataDataPhysical { quantity: number; price: number; - availability: "hamlet" | "village" | "city" | "elves" | "dwarves" | "nowhere" | "unset"; + availability: keyof typeof DS4.i18n.itemAvailabilities; storageLocation: string; } @@ -56,12 +56,13 @@ interface DS4ItemDataDataProtective { armorValue: number; } -interface UnitData { +export interface UnitData { value: string; unit: UnitType; } -type TemporalUnit = "rounds" | "minutes" | "hours" | "days" | "custom"; -type DistanceUnit = "meter" | "kilometer" | "custom"; +export type TemporalUnit = keyof typeof DS4.i18n.temporalUnits; +type CustomTemporalUnit = keyof typeof DS4.i18n.customTemporalUnits; +type DistanceUnit = keyof typeof DS4.i18n.distanceUnits; // types @@ -71,15 +72,15 @@ export interface DS4WeaponDataData extends DS4ItemDataDataBase, DS4ItemDataDataP opponentDefense: number; } -export type AttackType = keyof typeof DS4["i18n"]["attackTypes"]; +export type AttackType = keyof typeof DS4.i18n.attackTypes; export interface DS4ArmorDataData extends DS4ItemDataDataBase, DS4ItemDataDataPhysical, DS4ItemDataDataEquipable, DS4ItemDataDataProtective { - armorMaterialType: "cloth" | "leather" | "chain" | "plate"; - armorType: "body" | "helmet" | "vambrace" | "greaves" | "vambraceGreaves"; + armorMaterialType: keyof typeof DS4.i18n.armorMaterialTypes; + armorType: keyof typeof DS4.i18n.armorTypes; } export interface DS4TalentDataData extends DS4ItemDataDataBase { @@ -91,28 +92,18 @@ export interface DS4TalentRank extends ModifiableDataBase { } export interface DS4SpellDataData extends DS4ItemDataDataBase, DS4ItemDataDataEquipable { - spellType: "spellcasting" | "targetedSpellcasting"; + spellType: keyof typeof DS4.i18n.spellTypes; bonus: string; - spellCategory: - | "healing" - | "fire" - | "ice" - | "light" - | "darkness" - | "mindAffecting" - | "electricity" - | "none" - | "unset"; + spellCategory: keyof typeof DS4.i18n.spellCategories; maxDistance: UnitData; effectRadius: UnitData; - duration: UnitData; + duration: UnitData; cooldownDuration: UnitData; minimumLevels: { healer: number | null; wizard: number | null; sorcerer: number | null; }; - scrollPrice: number; } export interface DS4ShieldDataData diff --git a/src/module/item/item-prepared-data.ts b/src/module/item/item-prepared-data.ts index 4dede57f..6a9b4574 100644 --- a/src/module/item/item-prepared-data.ts +++ b/src/module/item/item-prepared-data.ts @@ -57,7 +57,9 @@ interface DS4ArmorPreparedDataData extends DS4ArmorDataData, DS4ItemPreparedData interface DS4ShieldPreparedDataData extends DS4ShieldDataData, DS4ItemPreparedDataDataRollable {} -interface DS4SpellPreparedDataData extends DS4SpellDataData, DS4ItemPreparedDataDataRollable {} +interface DS4SpellPreparedDataData extends DS4SpellDataData, DS4ItemPreparedDataDataRollable { + price: number | null; +} interface DS4EquipmentPreparedDataData extends DS4EquipmentDataData, DS4ItemPreparedDataDataRollable {} diff --git a/src/module/item/item-sheet.ts b/src/module/item/item-sheet.ts index 2906e9d4..edf49f0b 100644 --- a/src/module/item/item-sheet.ts +++ b/src/module/item/item-sheet.ts @@ -11,27 +11,12 @@ export class DS4ItemSheet extends ItemSheet> { static get defaultOptions(): BaseEntitySheet.Options { const superDefaultOptions = super.defaultOptions; return mergeObject(superDefaultOptions, { + ...superDefaultOptions, width: 540, height: 400, classes: ["ds4", "sheet", "item"], tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description" }], - scrollY: [".sheet-body"], - template: superDefaultOptions.template, - viewPermission: superDefaultOptions.viewPermission, - closeOnSubmit: superDefaultOptions.closeOnSubmit, - submitOnChange: superDefaultOptions.submitOnChange, - submitOnClose: superDefaultOptions.submitOnClose, - editable: superDefaultOptions.editable, - baseApplication: superDefaultOptions.baseApplication, - top: superDefaultOptions.top, - left: superDefaultOptions.left, - popOut: superDefaultOptions.popOut, - minimizable: superDefaultOptions.minimizable, - resizable: superDefaultOptions.resizable, - id: superDefaultOptions.id, - dragDrop: superDefaultOptions.dragDrop, - filters: superDefaultOptions.filters, - title: superDefaultOptions.title, + scrollY: [".tab.description", ".tab.effects", ".tab.details"], }); } diff --git a/src/module/item/item.ts b/src/module/item/item.ts index 00816b7c..7e0f07aa 100644 --- a/src/module/item/item.ts +++ b/src/module/item/item.ts @@ -1,8 +1,9 @@ import { DS4Actor } from "../actor/actor"; +import { hoursPerDay, minutesPerHour, secondsPerMinute, secondsPerRound } from "../common/time-helpers"; import { DS4 } from "../config"; import { createCheckRoll } from "../rolls/check-factory"; import notifications from "../ui/notifications"; -import { AttackType, DS4ItemData, ItemType } from "./item-data"; +import { AttackType, DS4ItemData, DS4SpellDataData, ItemType, TemporalUnit, UnitData } from "./item-data"; import { DS4ItemPreparedData } from "./item-prepared-data"; /** @@ -27,6 +28,56 @@ export class DS4Item extends Item { } else { this.data.data.rollable = false; } + if (this.data.type === "spell") { + this.data.data.price = this.calculateSpellPrice(this.data.data); + } + } + + protected calculateSpellPrice(data: DS4SpellDataData): number | null { + const spellPriceFactor = DS4Item.calculateSpellPriceFactor(data.cooldownDuration); + const baseSpellPrices = [ + data.minimumLevels.healer !== null ? 10 + (data.minimumLevels.healer - 1) * 35 : null, + data.minimumLevels.wizard !== null ? 10 + (data.minimumLevels.wizard - 1) * 50 : null, + data.minimumLevels.sorcerer !== null ? 10 + (data.minimumLevels.sorcerer - 1) * 65 : null, + ].filter((baseSpellPrice: number | null): baseSpellPrice is number => baseSpellPrice !== null); + const baseSpellPrice = Math.min(...baseSpellPrices); + return baseSpellPrice === Infinity ? null : baseSpellPrice * spellPriceFactor; + } + + protected static calculateSpellPriceFactor(temporalData: UnitData): number { + let days: number; + if (Number.isNumeric(temporalData.value)) { + switch (temporalData.unit) { + case "days": { + days = temporalData.value; + break; + } + case "hours": { + days = temporalData.value / hoursPerDay; + break; + } + case "minutes": { + days = temporalData.value / (hoursPerDay * minutesPerHour); + break; + } + case "rounds": { + days = (temporalData.value * secondsPerRound) / (hoursPerDay * minutesPerHour * secondsPerMinute); + break; + } + } + } else { + switch (temporalData.unit) { + case "days": { + days = 2; + break; + } + default: { + days = 0; + break; + } + } + } + return Math.clamped(Math.floor(days), 0, 2) + 1; } isNonEquippedEuipable(): boolean { diff --git a/src/module/migrations.ts b/src/module/migrations.ts index 525400c5..b60428cd 100644 --- a/src/module/migrations.ts +++ b/src/module/migrations.ts @@ -1,6 +1,7 @@ import { migrate as migrate001 } from "./migrations/001"; import { migrate as migrate002 } from "./migrations/002"; import { migrate as migrate003 } from "./migrations/003"; +import { migrate as migrate004 } from "./migrations/004"; import notifications from "./ui/notifications"; @@ -72,7 +73,7 @@ function getTargetMigrationVersion(): number { return migrations.length; } -const migrations: Array<() => Promise> = [migrate001, migrate002, migrate003]; +const migrations: Array<() => Promise> = [migrate001, migrate002, migrate003, migrate004]; function isFirstWorldStart(migrationVersion: number): boolean { return migrationVersion < 0; diff --git a/src/module/migrations/004.ts b/src/module/migrations/004.ts new file mode 100644 index 00000000..540f0390 --- /dev/null +++ b/src/module/migrations/004.ts @@ -0,0 +1,146 @@ +import { DS4SpellDataData } from "../item/item-data"; + +export async function migrate(): Promise { + await migrateItems(); + await migrateActors(); + await migrateScenes(); + await migrateCompendiums(); +} + +async function migrateItems() { + for (const item of game.items?.entities ?? []) { + try { + const updateData = getItemUpdateData(item._data); + if (updateData) { + console.log(`Migrating Item entity ${item.name} (${item.id})`); + await item.update(updateData), { enforceTypes: false }; + } + } catch (err) { + err.message = `Error during migration of Item entity ${item.name} (${item.id}), continuing anyways.`; + console.error(err); + } + } +} + +function getItemUpdateData(itemData: DeepPartial) { + if (!["spell"].includes(itemData.type ?? "")) return undefined; + const updateData: Record = { + "-=data.scrollPrice": null, + "data.minimumLevels": { healer: null, wizard: null, sorcerer: null }, + }; + if (((itemData.data as DS4SpellDataData).cooldownDuration.unit as string) === "custom") { + updateData["data.cooldownDuration.unit"] = "rounds"; + } + return updateData; +} + +async function migrateActors() { + for (const actor of game.actors?.entities ?? []) { + try { + const updateData = getActorUpdateData(actor._data); + if (updateData) { + console.log(`Migrating Actor entity ${actor.name} (${actor.id})`); + await actor.update(updateData, { enforceTypes: false }); + } + } catch (err) { + err.message = `Error during migration of Actor entity ${actor.name} (${actor.id}), continuing anyways.`; + console.error(err); + } + } +} + +function getActorUpdateData(actorData: DeepPartial) { + let hasItemUpdates = false; + const items = actorData.items?.map((itemData) => { + const update = itemData ? getItemUpdateData(itemData) : undefined; + if (update) { + hasItemUpdates = true; + return mergeObject(itemData, update, { enforceTypes: false, inplace: false }); + } else { + return itemData; + } + }); + return hasItemUpdates ? { items } : undefined; +} + +async function migrateScenes() { + for (const scene of game.scenes?.entities ?? []) { + try { + const updateData = getSceneUpdateData(scene._data); + if (updateData) { + console.log(`Migrating Scene entity ${scene.name} (${scene.id})`); + await scene.update(updateData, { enforceTypes: false }); + } + } catch (err) { + err.message = `Error during migration of Scene entity ${scene.name} (${scene.id}), continuing anyways.`; + console.error(err); + } + } +} + +function getSceneUpdateData(sceneData: Scene.Data) { + let hasTokenUpdates = false; + const tokens = sceneData.tokens.map((tokenData) => { + if (!tokenData.actorId || tokenData.actorLink || tokenData.actorData.data) { + tokenData.actorData = {}; + hasTokenUpdates = true; + return tokenData; + } + const token = new Token(tokenData); + if (!token.actor) { + tokenData.actorId = (null as unknown) as string; + tokenData.actorData = {}; + hasTokenUpdates = true; + } else if (!tokenData.actorLink) { + const actorUpdateData = getActorUpdateData(token.data.actorData); + tokenData.actorData = mergeObject(token.data.actorData, actorUpdateData); + hasTokenUpdates = true; + } + return tokenData; + }); + if (!hasTokenUpdates) return undefined; + return hasTokenUpdates ? { tokens } : undefined; +} + +async function migrateCompendiums() { + for (const compendium of game.packs ?? []) { + if (compendium.metadata.package !== "world") continue; + if (!["Actor", "Item", "Scene"].includes(compendium.metadata.entity)) continue; + await migrateCompendium(compendium); + } +} + +async function migrateCompendium(compendium: Compendium) { + const entityName = compendium.metadata.entity; + if (!["Actor", "Item", "Scene"].includes(entityName)) return; + const wasLocked = compendium.locked; + await compendium.configure({ locked: false }); + + const content = await compendium.getContent(); + + for (const entity of content) { + try { + const getUpdateData = (entity: Entity) => { + switch (entityName) { + case "Item": + return getItemUpdateData(entity._data); + case "Actor": + return getActorUpdateData(entity._data); + case "Scene": + return getSceneUpdateData(entity._data as Scene.Data); + } + }; + const updateData = getUpdateData(entity); + if (updateData) { + console.log(`Migrating entity ${entity.name} (${entity.id}) in compendium ${compendium.collection}`); + await compendium.updateEntity({ ...updateData, _id: entity._id }); + } + } catch (err) { + err.message = `Error during migration of entity ${entity.name} (${entity.id}) in compendium ${compendium.collection}, continuing anyways.`; + console.error(err); + } + } + + await compendium.migrate({}); + await compendium.configure({ locked: wasLocked }); +} diff --git a/src/scss/components/_description.scss b/src/scss/components/_description.scss index 01187302..f8b5312e 100644 --- a/src/scss/components/_description.scss +++ b/src/scss/components/_description.scss @@ -29,6 +29,10 @@ overflow: hidden; text-overflow: ellipsis; } + span { + line-height: variables.$default-input-height; + padding: 0 4px; + } @include mixins.mark-invalid-or-disabled-input; diff --git a/src/system.json b/src/system.json index 8ab132ac..bb9d36ee 100644 --- a/src/system.json +++ b/src/system.json @@ -5,7 +5,7 @@ "version": "0.6.0", "minimumCoreVersion": "0.7.9", "compatibleCoreVersion": "0.7.9", - "templateVersion": 5, + "templateVersion": 6, "author": "Johannes Loher, Gesina Schwalbe, Oliver Rümpelein, Siegfried Krug", "authors": [ { diff --git a/src/template.json b/src/template.json index 7a15b4a3..935c1d8a 100644 --- a/src/template.json +++ b/src/template.json @@ -212,14 +212,13 @@ }, "cooldownDuration": { "value": "", - "unit": "custom" + "unit": "rounds" }, "minimumLevels": { "healer": null, "wizard": null, "sorcerer": null - }, - "scrollPrice": 0 + } }, "specialCreatureAbility": { "templates": ["base"], diff --git a/src/templates/sheets/actor/tabs/spells.hbs b/src/templates/sheets/actor/tabs/spells.hbs index 58e78735..4e16590e 100644 --- a/src/templates/sheets/actor/tabs/spells.hbs +++ b/src/templates/sheets/actor/tabs/spells.hbs @@ -18,7 +18,7 @@ {{/inline}} {{!-- -!-- Two templates based on the "unit" template for displaying values with unit. +!-- Three templates based on the "unit" template for displaying values with unit. !-- Both accept a `config` object holding the unitNames and unitAbbr instead of !-- directly handing over the latter two. !-- @param titleKey: The key of the localized title to use. @@ -28,6 +28,11 @@ titleKey=titleKey}} {{/inline}} +{{#*inline "customTemporalUnit"}} +{{> unit unitNames=config.i18n.customTemporalUnits unitAbbrs=config.i18n.customTemporalUnitsAbbr unitDatum=unitDatum +titleKey=titleKey}} +{{/inline}} + {{#*inline "distanceUnit"}} {{> unit unitNames=config.i18n.distanceUnits unitAbbrs=config.i18n.distanceUnitsAbbr unitDatum=unitDatum titleKey=titleKey}} @@ -72,7 +77,7 @@ titleKey=titleKey}} config=../../config}} {{!-- duration --}} - {{> temporalUnit titleKey='DS4.SpellDuration' unitDatum=itemData.data.duration config=../../config}} + {{> customTemporalUnit titleKey='DS4.SpellDuration' unitDatum=itemData.data.duration config=../../config}} {{!-- cooldown duration --}} {{> temporalUnit titleKey='DS4.SpellCooldownDuration' unitDatum=itemData.data.cooldownDuration diff --git a/src/templates/sheets/item/spell-sheet.hbs b/src/templates/sheets/item/spell-sheet.hbs index 25e87f98..08c72e14 100644 --- a/src/templates/sheets/item/spell-sheet.hbs +++ b/src/templates/sheets/item/spell-sheet.hbs @@ -12,11 +12,19 @@ @@ -62,7 +70,7 @@ {{> unitDatum data=data property='maxDistance' localizeString='DS4.SpellMaxDistance' unitType='distance' }} {{> unitDatum data=data property='effectRadius' localizeString='DS4.SpellEffectRadius' unitType='distance' }} - {{> unitDatum data=data property='duration' localizeString='DS4.SpellDuration' unitType='temporal' }} + {{> unitDatum data=data property='duration' localizeString='DS4.SpellDuration' unitType='customTemporal' }} {{> unitDatum data=data property='cooldownDuration' localizeString='DS4.SpellCooldownDuration' unitType='temporal' }}
@@ -81,9 +89,8 @@ id="data.minimumLevels.sorcerer" value="{{data.minimumLevels.sorcerer}}" />
- - + + {{data.price}}
{{/systems/ds4/templates/sheets/item/components/body.hbs}}