Automatically calculate spell price

This commit is contained in:
Johannes Loher 2021-05-13 19:59:44 +02:00
parent 2bf3caac99
commit b9f7588f95
16 changed files with 273 additions and 58 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export const secondsPerRound = 5;
export const secondsPerMinute = 60;
export const minutesPerHour = 60;
export const hoursPerDay = 24;

View file

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

View file

@ -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<UnitType> {
export interface UnitData<UnitType> {
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<number> {
}
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<DistanceUnit>;
effectRadius: UnitData<DistanceUnit>;
duration: UnitData<TemporalUnit>;
duration: UnitData<CustomTemporalUnit>;
cooldownDuration: UnitData<TemporalUnit>;
minimumLevels: {
healer: number | null;
wizard: number | null;
sorcerer: number | null;
};
scrollPrice: number;
}
export interface DS4ShieldDataData

View file

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

View file

@ -11,27 +11,12 @@ export class DS4ItemSheet extends ItemSheet<ItemSheet.Data<DS4Item>> {
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"],
});
}

View file

@ -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<DS4ItemData, DS4ItemPreparedData> {
} 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<TemporalUnit>): 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 {

View file

@ -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<void>> = [migrate001, migrate002, migrate003];
const migrations: Array<() => Promise<void>> = [migrate001, migrate002, migrate003, migrate004];
function isFirstWorldStart(migrationVersion: number): boolean {
return migrationVersion < 0;

View file

@ -0,0 +1,146 @@
import { DS4SpellDataData } from "../item/item-data";
export async function migrate(): Promise<void> {
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<Item.Data>) {
if (!["spell"].includes(itemData.type ?? "")) return undefined;
const updateData: Record<string, unknown> = {
"-=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<Actor.Data>) {
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 });
}

View file

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

View file

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

View file

@ -212,14 +212,13 @@
},
"cooldownDuration": {
"value": "",
"unit": "custom"
"unit": "rounds"
},
"minimumLevels": {
"healer": null,
"wizard": null,
"sorcerer": null
},
"scrollPrice": 0
}
},
"specialCreatureAbility": {
"templates": ["base"],

View file

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

View file

@ -12,11 +12,19 @@
<select name="data.{{property}}.unit" data-type="String">
{{#select (lookup (lookup data property) 'unit')}}
{{#if (eq unitType 'temporal')}}
{{#each (lookup config.i18n 'temporalUnitsAbbr') as |value key|}}<option value="{{key}}">{{value}}</option>
{{#each (lookup config.i18n 'temporalUnitsAbbr') as |value key|}}
<option value="{{key}}">{{value}}</option>
{{/each}}
{{else}}
{{#each (lookup config.i18n 'distanceUnitsAbbr') as |value key|}}<option value="{{key}}">{{value}}</option>
{{#if (eq unitType 'customTemporal')}}
{{#each (lookup config.i18n 'customTemporalUnitsAbbr') as |value key|}}
<option value="{{key}}">{{value}}</option>
{{/each}}
{{else}}
{{#each (lookup config.i18n 'distanceUnitsAbbr') as |value key|}}
<option value="{{key}}">{{value}}</option>
{{/each}}
{{/if}}
{{/if}}
{{/select}}
</select>
@ -62,7 +70,7 @@
</div>
{{> 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'
}}
<div class="side-property" title="{{localize 'DS4.SpellMinimumLevelsHealer'}}">
@ -81,9 +89,8 @@
id="data.minimumLevels.sorcerer" value="{{data.minimumLevels.sorcerer}}" />
</div>
<div class="side-property">
<label for="data.scrollPrice">{{localize "DS4.SpellScrollPriceGold"}}</label>
<input type="number" min="0" max="9999" step="0.01" data-dtype="Number" name="data.scrollPrice"
id="data.scrollPrice" value="{{data.scrollPrice}}" />
<label for="data.price">{{localize "DS4.SpellPrice"}}</label>
<span name="data.price" id="data.price">{{data.price}}</span>
</div>
{{/systems/ds4/templates/sheets/item/components/body.hbs}}