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

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