const { HandlebarsApplicationMixin } = foundry.applications.api import { ARMOR_TYPE_CHOICES, CLASS_RESTRICTION_CHOICES, SYSTEM, WEAPON_PROFICIENCY_GROUPS } from "../../config/system.mjs" import { rollRarityCheck } from "../../rolls.mjs" export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) { static SHEET_MODES = { EDIT: 0, PLAY: 1 } constructor(options = {}) { super(options) this.#dragDrop = this.#createDragDropHandlers() } #dragDrop /** @override */ static DEFAULT_OPTIONS = { classes: ["oathhammer", "item"], position: { width: 600, height: "auto", }, form: { submitOnChange: true, }, window: { resizable: true, }, dragDrop: [{ dragSelector: "[data-drag]", dropSelector: null }], actions: { toggleSheet: OathHammerItemSheet.#onToggleSheet, editImage: OathHammerItemSheet.#onEditImage, rollRarity: OathHammerItemSheet.#onRollRarity, removeRune: OathHammerItemSheet.#onRemoveRune, openRune: OathHammerItemSheet.#onOpenRune, }, } _sheetMode = this.constructor.SHEET_MODES.PLAY _lockedReadOnly = false /** @override — prevent form submissions when this sheet is opened in read-only mode */ get isEditable() { if (this._lockedReadOnly) return false return super.isEditable } get isPlayMode() { return this._sheetMode === this.constructor.SHEET_MODES.PLAY } get isEditMode() { return this._sheetMode === this.constructor.SHEET_MODES.EDIT } /** @override */ async _prepareContext() { const context = await super._prepareContext() context.fields = this.document.schema.fields context.systemFields = this.document.system.schema.fields context.item = this.document context.system = this.document.system context.source = this.document.toObject() context.isEditMode = this.isEditMode context.isPlayMode = this.isPlayMode context.isEditable = this.isEditable if (this.document.system.description !== undefined) { context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.document.system.description ?? "", { async: true }) } if (this.document.system.magicEffect !== undefined) { context.enrichedMagicEffect = await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.document.system.magicEffect ?? "", { async: true }) } context.classRestrictionChoices = CLASS_RESTRICTION_CHOICES // Armor-specific numeric selects context.armorValueChoices = Object.fromEntries( Array.from({ length: 13 }, (_, i) => [i, String(i)]) ) context.penaltyChoices = Object.fromEntries( Array.from({ length: 6 }, (_, i) => [-i, String(-i)]) ) // Weapon-specific numeric selects context.damageModChoices = Object.fromEntries( Array.from({ length: 10 }, (_, i) => [i - 4, i - 4 >= 0 ? `+${i - 4}` : String(i - 4)]) ) context.apChoices = Object.fromEntries( Array.from({ length: 7 }, (_, i) => [i, String(i)]) ) // Skill choices for weapon skill override (empty = auto-detect) context.skillChoices = { "": `— ${game.i18n.localize("OATHHAMMER.Weapon.SkillOverrideAuto")} —`, ...Object.fromEntries( Object.entries(SYSTEM.SKILLS).map(([k, v]) => [k, game.i18n.localize(v.label)]) ) } // Class proficiency choices (for class-sheet checkboxes) context.armorTypeChoices = ARMOR_TYPE_CHOICES context.weaponGroupChoices = WEAPON_PROFICIENCY_GROUPS // Rune context — enrich effect HTML for each attached rune if (Array.isArray(this.document.system.runes)) { context.enrichedRunes = await Promise.all( this.document.system.runes.map(async (rune, idx) => ({ ...rune, idx, enrichedEffect: await foundry.applications.ux.TextEditor.implementation.enrichHTML(rune.effect ?? "", { async: true }), })) ) } context.acceptsRunes = this.#acceptsRunes() return context } /** Returns true if this item type can have runic spells attached. */ #acceptsRunes() { const type = this.document.type if (type === "armor" || type === "weapon") return true if (type === "magic-item" && this.document.system.itemType === "talisman") return true return false } /** Map runeType expected for each item type. */ static #EXPECTED_RUNE_TYPE = { armor: "armor", weapon: "weapon", "magic-item": "talisman", } /** @override */ _onRender(context, options) { super._onRender(context, options) this.#dragDrop.forEach((d) => d.bind(this.element)) if (this._lockedReadOnly) { this.element.querySelector('[data-action="toggleSheet"]')?.remove() for (const el of this.element.querySelectorAll( '.window-content input, .window-content select, .window-content textarea, .window-content button' )) { el.disabled = true } } for (const pm of this.element.querySelectorAll("prose-mirror[name]")) { pm.addEventListener("change", async (event) => { event.stopPropagation() await this.document.update({ [pm.name]: pm.value ?? "" }) }) } } #createDragDropHandlers() { return this.options.dragDrop.map((d) => { d.permissions = { dragstart: this._canDragStart.bind(this), drop: this._canDragDrop.bind(this), } d.callbacks = { dragstart: this._onDragStart.bind(this), dragover: this._onDragOver.bind(this), drop: this._onDrop.bind(this), } return new foundry.applications.ux.DragDrop.implementation(d) }) } _canDragStart(selector) { return this.isEditable } _canDragDrop(selector) { return this.isEditable && this.document.isOwner } _onDragStart(event) { if ("link" in event.target.dataset) return } _onDragOver(event) {} async _onDrop(event) { if (!this.#acceptsRunes()) return let dragData try { dragData = JSON.parse(event.dataTransfer.getData("text/plain")) } catch { return } if (dragData?.type !== "Item") return let spell try { spell = await fromUuid(dragData.uuid) } catch { return } if (!spell || spell.type !== "spell") return const sys = spell.system if (sys.tradition !== "runic") { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.NotRunic")) return } const expectedType = OathHammerItemSheet.#EXPECTED_RUNE_TYPE[this.document.type] if (sys.runeType !== expectedType) { ui.notifications.warn(game.i18n.format("OATHHAMMER.Rune.WrongType", { expected: game.i18n.localize(`OATHHAMMER.RuneType.${expectedType.charAt(0).toUpperCase() + expectedType.slice(1)}`), })) return } const current = this.document.system.runes ?? [] if (current.length >= 2) { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxRunes")) return } if (current.some(r => r.name === spell.name)) { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.Duplicate")) return } if (sys.isExalted && current.some(r => r.isExalted)) { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxExalted")) return } const snapshot = { sourceUuid: spell.uuid, name: spell.name, img: spell.img, runeType: sys.runeType, isExalted: sys.isExalted, difficultyValue: sys.difficultyValue, effect: sys.effect ?? "", duration: sys.duration ?? "", range: sys.range ?? "", spellSave: sys.spellSave ?? "", } await this.document.update({ "system.runes": [...current, snapshot] }) ui.notifications.info(game.i18n.format("OATHHAMMER.Rune.Attached", { name: spell.name })) } static async #onRemoveRune(event, target) { const idx = parseInt(target.dataset.runeIndex, 10) if (isNaN(idx)) return const runes = [...(this.document.system.runes ?? [])] runes.splice(idx, 1) await this.document.update({ "system.runes": runes }) } static async #onOpenRune(event, target) { const uuid = target.dataset.sourceUuid if (!uuid) return try { const spell = await fromUuid(uuid) if (!spell) { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound")) return } // Use a unique ID so this read-only instance never conflicts with the // document's normal sheet (which uses {ClassName}-{documentId} as its ID). const SheetClass = spell.sheet.constructor const sheet = new SheetClass({ document: spell, id: `${SheetClass.name}-rune-view-${spell.id}`, }) sheet._lockedReadOnly = true sheet.render(true) } catch { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound")) } } static #onToggleSheet(event, target) { if (this._lockedReadOnly) return const modes = this.constructor.SHEET_MODES this._sheetMode = this.isEditMode ? modes.PLAY : modes.EDIT this.render() } static async #onEditImage(event, target) { const attr = target.dataset.edit const current = foundry.utils.getProperty(this.document, attr) const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {} const fp = new FilePicker({ current, type: "image", redirectToRoot: img ? [img] : [], callback: (path) => { this.document.update({ [attr]: path }) }, top: this.position.top + 40, left: this.position.left + 10, }) return fp.browse() } static async #onRollRarity(event, target) { const rarity = this.document.system.rarity if (!rarity) return // Find the owning actor (embedded item) or prompt user to select a character let actor = this.document.parent if (!actor || actor.documentName !== "Actor") { // Item not embedded — use the user's selected character actor = game.user.character if (!actor) { ui.notifications.warn(game.i18n.localize("OATHHAMMER.Roll.NoActor")) return } } await rollRarityCheck(actor, rarity, this.document.name) } }