Skip to content

Commit

Permalink
Split homebrew files (foundryvtt#15334)
Browse files Browse the repository at this point in the history
  • Loading branch information
CarlosFdez committed Jul 3, 2024
1 parent afa8795 commit 1331fa4
Show file tree
Hide file tree
Showing 3 changed files with 314 additions and 301 deletions.
115 changes: 115 additions & 0 deletions src/module/system/settings/homebrew/damage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { WeaponTrait } from "@item/weapon/types.ts";
import { immunityTypes, resistanceTypes, weaknessTypes } from "@scripts/config/iwr.ts";
import { DamageType } from "@system/damage/types.ts";
import {
BASE_DAMAGE_TYPES_TO_CATEGORIES,
DAMAGE_TYPES,
DAMAGE_TYPE_ICONS,
ENERGY_DAMAGE_TYPES,
PHYSICAL_DAMAGE_TYPES,
} from "@system/damage/values.ts";
import { ErrorPF2e, sluggify, tupleHasValue } from "@util";
import * as R from "remeda";
import { CustomDamageData } from "./data.ts";
import { isHomebrewCustomDamage } from "./helpers.ts";
import { HomebrewElements } from "./menu.ts";

/**
* To update all custom damage types in the system, we need to ensure that all collections are added to and cleaned.
* This reduces the scope of all damage related operations so that its easier to identify when something goes wrong.
*/
export class DamageTypeManager {
// All collections that homebrew damage must be updated in
collections = {
physical: PHYSICAL_DAMAGE_TYPES as unknown as string[],
energy: ENERGY_DAMAGE_TYPES as unknown as string[],
DAMAGE_TYPES,
BASE_DAMAGE_TYPES_TO_CATEGORIES,
DAMAGE_TYPE_ICONS,
damageTypesLocalization: CONFIG.PF2E.damageTypes,
damageRollFlavorsLocalization: CONFIG.PF2E.damageRollFlavors,
immunityTypes: immunityTypes as Record<string, string>,
weaknessTypes: weaknessTypes as Record<string, string>,
resistanceTypes: resistanceTypes as Record<string, string>,
};

addCustomDamage(data: CustomDamageData, options: { slug?: string } = {}): void {
const collections = this.collections;
const slug = (options.slug ?? sluggify(data.label)) as DamageType;
collections.DAMAGE_TYPES.add(slug);
if (tupleHasValue(["physical", "energy"], data.category)) {
collections[data.category].push(slug);
}
collections.BASE_DAMAGE_TYPES_TO_CATEGORIES[slug] = data.category ?? null;
collections.DAMAGE_TYPE_ICONS[slug] = data.icon?.substring(3) ?? null; // icons registered do not include the fa-
collections.damageTypesLocalization[slug] = data.label;

const versatileLabel = game.i18n.format("PF2E.TraitVersatileX", { x: data.label });
CONFIG.PF2E.weaponTraits[`versatile-${slug}` as WeaponTrait] = versatileLabel;
CONFIG.PF2E.npcAttackTraits[`versatile-${slug}` as WeaponTrait] = versatileLabel;

const damageFlavor = game.i18n.localize(data.label).toLocaleLowerCase(game.i18n.lang);
collections.damageRollFlavorsLocalization[slug] = damageFlavor;
collections.immunityTypes[slug] = damageFlavor;
collections.weaknessTypes[slug] = damageFlavor;
collections.resistanceTypes[slug] = damageFlavor;
}

updateSettings(): void {
const reservedTerms = HomebrewElements.reservedTerms;

// Delete all existing homebrew damage types first
const typesToDelete: Set<string> = DAMAGE_TYPES.filter((t) => !reservedTerms.damageTypes.has(t));
for (const collection of Object.values(this.collections)) {
if (collection instanceof Set) {
const types = [...collection].filter((t) => typesToDelete.has(t));
for (const damageType of types) collection.delete(damageType);
} else {
const types = Object.keys(collection).filter((t): t is keyof typeof collection => typesToDelete.has(t));
for (const damageType of types) delete collection[damageType];
}
}

// Delete versatile damage traits
for (const type of typesToDelete) {
const weaponTraits: Record<string, string> = CONFIG.PF2E.weaponTraits;
const npcAttackTraits: Record<string, string> = CONFIG.PF2E.npcAttackTraits;
delete weaponTraits[`versatile-${type}`];
delete npcAttackTraits[`versatile-${type}`];
}

// Read module damage types
const activeModules = [...game.modules.entries()].filter(([_key, foundryModule]) => foundryModule.active);
for (const [key, foundryModule] of activeModules) {
const homebrew = foundryModule.flags[key]?.["pf2e-homebrew"];
if (!R.isPlainObject(homebrew) || !homebrew.damageTypes) continue;

const elements = homebrew.damageTypes;
if (!R.isPlainObject(elements) || !isHomebrewCustomDamage(elements)) {
console.warn(ErrorPF2e(`Homebrew record damageTypes is malformed in module ${key}`).message);
continue;
}

for (const [slug, data] of Object.entries(elements)) {
if (!reservedTerms.damageTypes.has(slug)) {
this.addCustomDamage(data, { slug });
} else {
console.warn(
ErrorPF2e(
`Homebrew damage type "${slug}" from module ${foundryModule.title} is a reserved term.`,
).message,
);
continue;
}
}
}

// Read setting damage types
const customTypes = game.settings
.get("pf2e", "homebrew.damageTypes")
.filter((t) => !reservedTerms.damageTypes.has(sluggify(t.label)));
for (const data of customTypes) {
this.addCustomDamage(data);
}
}
}
195 changes: 195 additions & 0 deletions src/module/system/settings/homebrew/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { LANGUAGES, LANGUAGE_RARITIES } from "@actor/creature/values.ts";
import {
ErrorPF2e,
SORTABLE_BASE_OPTIONS,
htmlClosest,
htmlQuery,
htmlQueryAll,
localizer,
objectHasKey,
setHasElement,
sluggify,
tupleHasValue,
} from "@util";
import * as R from "remeda";
import Sortable from "sortablejs";
import { HomebrewTag, LanguageNotCommon, LanguageSettings, LanguageSettingsSheetData } from "./data.ts";
import { HomebrewElements } from "./menu.ts";

/** A helper class for managing languages and their rarities */
export class LanguagesManager {
/** The parent settings menu */
menu: HomebrewElements;

/** A separate list of module-provided languages */
moduleLanguages: LanguageNotCommon[];

constructor(menu: HomebrewElements) {
this.menu = menu;

const languagesFromSetting = game.settings.get("pf2e", "homebrew.languages").map((l) => l.id);
this.moduleLanguages = R.keys
.strict(CONFIG.PF2E.languages)
.filter(
(l): l is LanguageNotCommon =>
l !== "common" && !LANGUAGES.includes(l) && !languagesFromSetting.includes(l),
);

this.data.reset();
}

get data(): LanguageSettings {
return this.menu.cache.languageRarities;
}

getSheetData(): LanguageSettingsSheetData {
const data = this.data.toObject(false);
const homebrewLanguages = this.menu.cache.languages;
return {
commonLanguage: data.commonLanguage,
...R.mapToObj([...LANGUAGE_RARITIES, "unavailable"] as const, (r) => [
r,
data[r]
.map((slug) => {
const locKey =
CONFIG.PF2E.languages[slug] ?? homebrewLanguages.find((l) => l.id === slug)?.value ?? slug;
return { slug, label: game.i18n.localize(locKey) };
})
.sort((a, b) => a.label.localeCompare(b.label)),
]),
};
}

/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */

activateListeners(html: HTMLElement): void {
const commonLanguageSelect = htmlQuery<HTMLSelectElement>(html, "select[data-common-language");
commonLanguageSelect?.addEventListener("change", async (event) => {
event.stopPropagation();
const data = this.data;
const newCommon = commonLanguageSelect.value || null;
if (newCommon === null || setHasElement(data.common, newCommon)) {
data.updateSource({ commonLanguage: newCommon });
}
});

for (const list of htmlQueryAll(html, "ul[data-languages]")) {
new Sortable(list, {
...SORTABLE_BASE_OPTIONS,
group: "languages",
sort: false,
swapThreshold: 1,
onEnd: (event) => this.#onDropLanguage(event),
});
}

const rarities: readonly string[] = LANGUAGE_RARITIES;
const localize = localizer("PF2E.SETTINGS.Homebrew.Languages.Rarities");
for (const raritySection of htmlQueryAll(html, ".form-group.language-rarity")) {
const rarity = Array.from(raritySection.classList).find((c) => rarities.includes(c)) ?? "unavailable";
if (rarity === "unavailable") continue;
const labelEl = raritySection.querySelector("label");
if (!labelEl) throw ErrorPF2e("");

labelEl.innerHTML = localize(sluggify(rarity, { camel: "bactrian" }));
game.pf2e.TextEditor.convertXMLNode(labelEl, "rarity", { classes: ["tag", "rarity", rarity] });
}
}

async #onDropLanguage(event: Sortable.SortableEvent): Promise<void> {
const droppedEl = event.item;
const dropTarget = htmlClosest(droppedEl, "ul[data-languages]");
const oldRarity = droppedEl.dataset.rarity;
const newRarity = dropTarget?.dataset.rarity;
if (!(oldRarity && newRarity)) throw ErrorPF2e("Unexpected update to language rarities");
if (oldRarity === newRarity) return;

const language = droppedEl.dataset.slug;
if (!objectHasKey(CONFIG.PF2E.languages, language) || language === "common") {
throw ErrorPF2e("Unexpected update to language rarities");
}

const data = this.data;
const source = data.toObject();
const commonLanguageSelect = htmlQuery<HTMLSelectElement>(this.menu.form, "select[data-common-language]");
if (!commonLanguageSelect) throw ErrorPF2e("Unexpected error updating menu");

const rarities = ["uncommon", "rare", "secret", "unavailable"] as const;

if (newRarity === "common") {
for (const rarity of rarities) {
source[rarity].findSplice((l) => l === language);
}
// Add `commonLanguageOption` without full re-render
const newOption = document.createElement("option");
newOption.value = language;
newOption.textContent = droppedEl.textContent;
commonLanguageSelect.append(newOption);
} else {
if (!tupleHasValue(rarities, newRarity)) {
throw ErrorPF2e("Unexpected update to language rarities");
}
for (const rarity of rarities) {
source[rarity].findSplice((l) => l === language);
}
source[newRarity].push(language);
source[newRarity].sort();
source.commonLanguage = source.commonLanguage === language ? null : source.commonLanguage;

// Remove `commonLanguage` option without full re-render
if (commonLanguageSelect.value === language) commonLanguageSelect.value = "";
const option = htmlQuery<HTMLOptionElement>(commonLanguageSelect, `option[value=${language}]`);
option?.remove();
}

droppedEl.dataset.rarity = newRarity;
data.updateSource(source);
}

/** Update the language rarities cache, adding and deleting from sets as necessary. */
onChangeHomebrewLanguages(languages: HomebrewTag<"languages">[]): void {
const data = this.data;
const source = data.toObject();
const languageSet = new Set(LANGUAGES);
const updatedLanguages = [...this.moduleLanguages, ...languages.map((l) => l.id)];

let render = false;

if (
source.commonLanguage &&
!languageSet.has(source.commonLanguage) &&
!updatedLanguages.includes(source.commonLanguage)
) {
source.commonLanguage = null;
render = true;
}

for (const rarity of ["uncommon", "rare", "secret", "unavailable"] as const) {
for (const language of source[rarity]) {
if (!languageSet.has(language) && !updatedLanguages.includes(language)) {
source[rarity].findSplice((l) => l === language);
render = true;
}
}
}
data.updateSource(source);

for (const language of data.common) {
if (!languageSet.has(language) && !updatedLanguages.includes(language)) {
data.common.delete(language);
render = true;
}
}

for (const language of updatedLanguages) {
if (!LANGUAGE_RARITIES.some((r) => data[r].has(language))) {
data.common.add(language);
render = true;
}
}

if (render) this.menu.render();
}
}
Loading

0 comments on commit 1331fa4

Please sign in to comment.