"use strict"; // TODO(Future) {@tags} added to state in post-processing steps are not visible in their input boxes without refresh. See the spell builder for how this should be implemented. // - Same applies for UiUtil.strToInt'd inputs class CreatureBuilder extends Builder { constructor () { super({ titleSidebarLoadExisting: "Copy Existing Creature", titleSidebarDownloadJson: "Download Creatures as JSON", metaSidebarDownloadMarkdown: { title: "Download Creatures as Markdown", pFnGetText: (mons) => { return RendererMarkdown.monster.pGetMarkdownDoc(mons); }, }, prop: "monster", }); this._bestiaryFluffIndex = null; this._bestiaryTypeTags = null; this._legendaryGroups = null; this._$selLegendaryGroup = null; this._legendaryGroupCache = null; // region Indexed template creature actions and traits this._jsonCreatureActions = null; this._indexedActions = null; this._jsonCreatureTraits = null; this._indexedTraits = null; // endregion this._renderOutputDebounced = MiscUtil.debounce(() => this._renderOutput(), 50); this._generateAttackCache = null; } static _getAsMarkdown (mon) { return RendererMarkdown.get().render({entries: [{type: "statblockInline", dataType: "monster", data: mon}]}); } async pHandleSidebarLoadExistingClick () { const result = await SearchWidget.pGetUserCreatureSearch(); if (result) { const creature = MiscUtil.copy(await DataLoader.pCacheAndGet(result.page, result.source, result.hash)); return this.pHandleSidebarLoadExistingData(creature); } } /** * @param creature * @param [opts] * @param [opts.isForce] * @param [opts.meta] */ async pHandleSidebarLoadExistingData (creature, opts) { opts = opts || []; const cleanOrigin = window.location.origin.replace(/\/+$/, ""); if (creature.hasToken) { creature.token = { name: creature.name, source: creature.source, }; } // Get the fluff based on the original source if (this._bestiaryFluffIndex[creature.source] && !creature.fluff) { const fluff = await Renderer.monster.pGetFluff(creature); if (fluff) creature.fluff = MiscUtil.copy(fluff); } creature.source = this._ui.source; if (creature.soundClip && creature.soundClip.type === "internal") { creature.soundClip = { type: "external", url: `${cleanOrigin}/${Renderer.utils.getEntryMediaUrl(creature, "soundClip", "audio")}`, }; } delete creature.otherSources; delete creature.srd; delete creature.altArt; delete creature.hasToken; delete creature.uniqueId; delete creature._versions; delete creature.reprintedAs; if (creature.variant) creature.variant.forEach(ent => delete ent._version); // Semi-gracefully handle e.g. ERLW's Steel Defender if (creature.passive != null && typeof creature.passive === "string") delete creature.passive; const meta = {...(opts.meta || {}), ...this._getInitialMetaState()}; if (ScaleCreature.isCrInScaleRange(creature) && !opts.isForce) { const crDefault = creature.cr.cr || creature.cr; const scaleTo = await InputUiUtil.pGetUserScaleCr({ title: "At Challenge Rating...", default: crDefault, }); if (scaleTo != null && scaleTo !== crDefault) { const scaled = await ScaleCreature.scale(creature, Parser.crToNumber(scaleTo)); delete scaled._displayName; this.setStateFromLoaded({s: scaled, m: meta}); } else this.setStateFromLoaded({s: creature, m: meta}); } else if (creature.summonedBySpellLevel && !opts.isForce) { const fauxSel = Renderer.monster.getSelSummonSpellLevel(creature); const values = [...fauxSel.options].map(it => it.value === "-1" ? "\u2014" : Number(it.value)); const scaleTo = await InputUiUtil.pGetUserEnum({values: values, title: "At Spell Level...", default: values[0], isResolveItem: true}); if (scaleTo != null) { const scaled = await ScaleSpellSummonedCreature.scale(creature, scaleTo); delete scaled._displayName; this.setStateFromLoaded({s: scaled, m: meta}); } else this.setStateFromLoaded({s: creature, m: meta}); } else if (creature.summonedByClass && !opts.isForce) { const fauxSel = Renderer.monster.getSelSummonClassLevel(creature); const values = [...fauxSel.options].map(it => it.value === "-1" ? "\u2014" : Number(it.value)); const scaleTo = await InputUiUtil.pGetUserEnum({values: values, title: "At Class Level...", default: values[0], isResolveItem: true}); if (scaleTo != null) { const scaled = await ScaleClassSummonedCreature.scale(creature, scaleTo); delete scaled._displayName; this.setStateFromLoaded({s: scaled, m: meta}); } else this.setStateFromLoaded({s: creature, m: meta}); } else this.setStateFromLoaded({s: creature, m: meta}); this.renderInput(); this.renderOutput(); } async _pHashChange_pHandleSubHashes (sub, toLoad) { if (!sub.length) return super._pHashChange_pHandleSubHashes(sub, toLoad); const scaledHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED)); const scaledSpellSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_SPELL_SUMMON)); const scaledClassSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_CLASS_SUMMON)); if ( !scaledHash && !scaledSpellSummonHash && !scaledClassSummonHash ) return super._pHashChange_pHandleSubHashes(sub, toLoad); if (scaledHash) { const scaleTo = Number(UrlUtil.unpackSubHash(scaledHash)[VeCt.HASH_SCALED][0]); try { toLoad = await ScaleCreature.scale(toLoad, scaleTo); delete toLoad._displayName; } catch (e) { setTimeout(() => { throw e; }); } } else if (scaledSpellSummonHash) { const scaleTo = Number(UrlUtil.unpackSubHash(scaledSpellSummonHash)[VeCt.HASH_SCALED_SPELL_SUMMON][0]); try { toLoad = await ScaleSpellSummonedCreature.scale(toLoad, scaleTo); delete toLoad._displayName; } catch (e) { setTimeout(() => { throw e; }); } } else if (scaledClassSummonHash) { const scaleTo = Number(UrlUtil.unpackSubHash(scaledClassSummonHash)[VeCt.HASH_SCALED_CLASS_SUMMON][0]); try { toLoad = await ScaleClassSummonedCreature.scale(toLoad, scaleTo); delete toLoad._displayName; } catch (e) { setTimeout(() => { throw e; }); } } return { isAllowEditExisting: false, toLoad, }; } async _pInit () { const [bestiaryFluffIndex, jsonCreature, items] = await Promise.all([ DataUtil.loadJSON("data/bestiary/fluff-index.json"), DataUtil.loadJSON("data/makebrew-creature.json"), Renderer.item.pBuildList(), DataUtil.monster.pPreloadMeta(), ]); this._bestiaryFluffIndex = bestiaryFluffIndex; MiscTag.init({items}); AttachedItemTag.init({items}); await this._pBuildLegendaryGroupCache(); this._jsonCreatureTraits = [ ...jsonCreature.makebrewCreatureTrait, ...((await PrereleaseUtil.pGetBrewProcessed()).makebrewCreatureTrait || []), ...((await BrewUtil2.pGetBrewProcessed()).makebrewCreatureTrait || []), ]; this._indexedTraits = elasticlunr(function () { this.addField("n"); this.setRef("id"); }); SearchUtil.removeStemmer(this._indexedTraits); this._jsonCreatureTraits.forEach((it, i) => this._indexedTraits.addDoc({ n: it.name, id: i, })); this._jsonCreatureActions = [ ...items .filter(it => !it._isItemGroup && it._category === "Basic" && (it.type === "M" || it.type === "R") && it.dmg1 && it.dmgType) .map(item => { const mDice = /^(?\d+)d(?\d+)\b/i.exec(item.dmg1); if (!mDice) return null; const abil = item.type === "M" ? "str" : "dex"; const ptAtk = `${item.type === "M" ? "m" : "r"}w${item.type === "M" && item.range ? `,rw` : ""}`; const ptRange = item.range ? `${item.type === "M" ? `reach 5 ft. or ` : ""}range ${item.range} ft.` : "reach 5 ft."; const dmgAvg = Number(mDice.groups.count) * ((Number(mDice.groups.face) + 1) / 2); const isFinesse = !!item?.property?.includes("F"); return { name: item.name, entries: [ `{@atk ${ptAtk}} {@hit <$to_hit__${abil}$>} to hit, ${ptRange}, one target. {@h}<$damage_avg__(size_mult*${dmgAvg})+${abil}$> ({@damage <$size_mult__${mDice.groups.count}$>d${mDice.groups.face}<$damage_mod__${abil}$>}) ${Parser.dmgTypeToFull(item.dmgType)} damage.`, ], entriesFinesse: isFinesse ? [ `{@atk ${ptAtk}} {@hit <$to_hit__dex$>} to hit, ${ptRange}, one target. {@h}<$damage_avg__(size_mult*${dmgAvg})+dex$> ({@damage <$size_mult__${mDice.groups.count}$>d${mDice.groups.face}<$damage_mod__dex$>}) ${Parser.dmgTypeToFull(item.dmgType)} damage.`, ] : null, }; }) .filter(Boolean), ...jsonCreature.makebrewCreatureAction, ...((await PrereleaseUtil.pGetBrewProcessed()).makebrewCreatureAction || []), ...((await BrewUtil2.pGetBrewProcessed()).makebrewCreatureAction || []), ]; this._indexedActions = elasticlunr(function () { this.addField("n"); this.setRef("id"); }); SearchUtil.removeStemmer(this._indexedActions); this._jsonCreatureActions.forEach((it, i) => this._indexedActions.addDoc({ n: it.name, id: i, })); // Load this asynchronously, to avoid blocking the page load this._bestiaryTypeTags = []; const allTypes = new Set(); DataUtil.monster.pLoadAll().then(mons => { mons.forEach(mon => mon.type && mon.type.tags ? mon.type.tags.forEach(tp => allTypes.add(tp.tag || tp)) : ""); this._bestiaryTypeTags.push(...allTypes); }); } _getInitialState () { return { ...super._getInitialState(), name: "New Creature", size: [ "M", ], type: "aberration", source: this._ui ? this._ui.source : "", alignment: ["N"], ac: [10], hp: {average: 4, formula: "1d8"}, speed: {walk: 30}, str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10, passive: 10, cr: "0", }; } setStateFromLoaded (state) { if (!state?.s || !state?.m) return; // TODO validate state this._doResetProxies(); if (!state.s.uniqueId) state.s.uniqueId = CryptUtil.uid(); // clean old language/sense formats if (state.s.languages && !(state.s.languages instanceof Array)) state.s.languages = [state.s.languages]; if (state.s.senses && !(state.s.senses instanceof Array)) state.s.senses = [state.s.senses]; this.__state = state.s; this.__meta = state.m; // auto-set proficiency toggles (1 = proficient; 2 = expert) if (!state.m.profSave) { state.m.profSave = {}; if (state.s.save) { const pb = this._getProfBonus(); Object.entries(state.s.save).forEach(([prop, val]) => { const expected = Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, prop, {isDefaultTen: true})) + pb; if (Number(val) === Number(expected)) state.m.profSave[prop] = 1; }); } } if (!state.m.profSkill) { state.m.profSkill = {}; if (state.s.skill) { const pb = this._getProfBonus(); Object.entries(state.s.skill).forEach(([prop, val]) => { const abilProp = Parser.skillToAbilityAbv(prop); const abilMod = Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, abilProp, {isDefaultTen: true})); const expectedProf = abilMod + pb; if (Number(val) === Number(expectedProf)) return state.m.profSkill[prop] = 1; const expectedExpert = abilMod + 2 * pb; if (Number(val) === Number(expectedExpert)) state.m.profSkill[prop] = 2; }); } } // other fields which don't fall under proficiency if (!state.m.autoCalc) { state.m.autoCalc = { proficiency: true, }; // hit points if (state.s.hp.formula && state.s.hp.average != null) { const expected = Math.floor(Renderer.dice.parseAverage(state.s.hp.formula)); state.m.autoCalc.hpAverageSimple = expected === state.s.hp.average; state.m.autoCalc.hpAverageComplex = state.m.autoCalc.hpAverageSimple; const parts = CreatureBuilder.__$getHpInput__getFormulaParts(state.s.hp.formula); if (parts) { const mod = Parser.getAbilityModNumber(this.__state.con); const expected = mod * parts.hdNum; if (expected === (parts.mod || 0)) state.m.autoCalc.hpModifier = true; } } else { // enable auto-calc for "Special" HP types; hidden until mode switch state.m.autoCalc.hpAverage = true; state.m.autoCalc.hpModifier = true; } // passive perception const expectedPassive = (state.s.skill && state.s.skill.perception ? Number(state.s.skill.perception) : Parser.getAbilityModNumber(this.__state.wis)) + 10; if (state.s.passive && expectedPassive === state.s.passive) state.m.autoCalc.passivePerception = true; } this.doUiSave(); } _reset_mutNextMetaState ({metaNext}) { if (!metaNext) return; metaNext.autoCalc = MiscUtil.copy(this._meta?.autoCalc || {}); } doHandleSourcesAdd () { (this._$eles.$selVariantSources || []).map($sel => { const currSrcJson = $sel.val(); $sel.empty().append(``); this._ui.allSources.forEach(srcJson => $sel.append(``)); if (this._ui.allSources.indexOf(currSrcJson)) $sel.val(currSrcJson); else $sel[0].selectedIndex = 0; return $sel; }).forEach($sel => $sel.change()); } _renderInputImpl () { this._validateMeta(); this.doCreateProxies(); this.renderInputControls(); this._renderInputMain(); } _validateMeta () { // ensure expected objects exist const setOn = this._meta || this.__meta; if (!setOn.profSave) setOn.profSave = {}; if (!setOn.profSkill) setOn.profSkill = {}; if (!setOn.autoCalc) setOn.autoCalc = {}; } _renderInputMain () { this._sourcesCache = MiscUtil.copy(this._ui.allSources); const $wrp = this._ui.$wrpInput.empty(); const _cb = () => { // Prefer numerical pages if possible if (!isNaN(this._state.page)) this._state.page = Number(this._state.page); Renderer.monster.updateParsed(this._state); // do post-processing DiceConvert.cleanHpDice(this._state); TagAttack.tryTagAttacks(this._state); TagHit.tryTagHits(this._state); TagDc.tryTagDcs(this._state); TagCondition.tryTagConditions(this._state, {isTagInflicted: true}); TraitActionTag.tryRun(this._state); LanguageTag.tryRun(this._state); SenseFilterTag.tryRun(this._state); SpellcastingTypeTag.tryRun(this._state); DamageTypeTag.tryRun(this._state); DamageTypeTag.tryRunSpells(this._state); DamageTypeTag.tryRunRegionalsLairs(this._state); CreatureSavingThrowTagger.tryRun(this._state); CreatureSavingThrowTagger.tryRunSpells(this._state); CreatureSavingThrowTagger.tryRunRegionalsLairs(this._state); MiscTag.tryRun(this._state); TagImmResVulnConditional.tryRun(this._state); DragonAgeTag.tryRun(this._state); AttachedItemTag.tryRun(this._state); this.renderOutput(); this.doUiSave(); this._meta.isModified = true; }; const cb = MiscUtil.debounce(_cb, 33); this._cbCache = cb; // cache for use when updating sources // initialise tabs this._resetTabs({tabGroup: "input"}); const tabs = this._renderTabs( [ new TabUiUtil.TabMeta({name: "Info", hasBorder: true}), new TabUiUtil.TabMeta({name: "Species", hasBorder: true}), new TabUiUtil.TabMeta({name: "Core", hasBorder: true}), new TabUiUtil.TabMeta({name: "Defence", hasBorder: true}), new TabUiUtil.TabMeta({name: "Abilities", hasBorder: true}), new TabUiUtil.TabMeta({name: "Flavor/Misc", hasBorder: true}), ], { tabGroup: "input", cbTabChange: this.doUiSave.bind(this), }, ); const [infoTab, speciesTab, coreTab, defenseTab, abilTab, miscTab] = tabs; $$`
${tabs.map(it => it.$btnTab)}
`.appendTo($wrp); tabs.forEach(it => it.$wrpTab.appendTo($wrp)); // INFO BuilderUi.$getStateIptString("Name", cb, this._state, {nullable: false, callback: () => this.pRenderSideMenu()}, "name").appendTo(infoTab.$wrpTab); this.__$getShortNameInput(cb).appendTo(infoTab.$wrpTab); this._$selSource = this.$getSourceInput(cb).appendTo(infoTab.$wrpTab); BuilderUi.$getStateIptString("Page", cb, this._state, {}, "page").appendTo(infoTab.$wrpTab); this.__$getAlignmentInput(cb).appendTo(infoTab.$wrpTab); this.__$getCrInput(cb).appendTo(infoTab.$wrpTab); this.__$getProfBonusInput(cb).appendTo(infoTab.$wrpTab); this.__$getProfNoteInput(cb).appendTo(infoTab.$wrpTab); BuilderUi.$getStateIptNumber("Level", cb, this._state, {title: "Used for Sidekicks only"}, "level").appendTo(infoTab.$wrpTab); // SPECIES this.__$getSizeInput(cb).appendTo(speciesTab.$wrpTab); this.__$getTypeInput(cb).appendTo(speciesTab.$wrpTab); this.__$getSpeedInput(cb).appendTo(speciesTab.$wrpTab); this.__$getSenseInput(cb).appendTo(speciesTab.$wrpTab); this.__$getLanguageInput(cb).appendTo(speciesTab.$wrpTab); // CORE this.__$getAbilityScoreInput(cb).appendTo(coreTab.$wrpTab); this.__$getSaveInput(cb).appendTo(coreTab.$wrpTab); this.__$getSkillInput(cb).appendTo(coreTab.$wrpTab); this.__$getPassivePerceptionInput(cb).appendTo(coreTab.$wrpTab); // DEFENCE this.__$getAcInput(cb).appendTo(defenseTab.$wrpTab); this.__$getHpInput(cb).appendTo(defenseTab.$wrpTab); this.__$getVulnerableInput(cb).appendTo(defenseTab.$wrpTab); this.__$getResistInput(cb).appendTo(defenseTab.$wrpTab); this.__$getImmuneInput(cb).appendTo(defenseTab.$wrpTab); this.__$getCondImmuneInput(cb).appendTo(defenseTab.$wrpTab); // ABILITIES this.__$getSpellcastingInput(cb).appendTo(abilTab.$wrpTab); this.__$getTraitInput(cb).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptEntries("Actions Intro", cb, this._state, {}, "actionHeader").appendTo(abilTab.$wrpTab); this.__$getActionInput(cb).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptEntries("Bonus Actions Intro", cb, this._state, {}, "bonusHeader").appendTo(abilTab.$wrpTab); this.__$getBonusActionInput(cb).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptEntries("Reactions Intro", cb, this._state, {}, "reactionHeader").appendTo(abilTab.$wrpTab); this.__$getReactionInput(cb).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptNumber( "Legendary Action Count", cb, this._state, { title: "If specified, this will override the default number (3) of legendary actions available for the creature.", placeholder: "If left blank, defaults to 3.", }, "legendaryActions", ).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptBoolean( "Name is Proper Noun", cb, this._state, { title: "If selected, the legendary action intro text for this creature will be formatted as though the creature's name is a proper noun (e.g. 'Tiamat can take...' vs 'The dragon can take...').", }, "isNamedCreature", ).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptEntries( "Legendary Action Intro", cb, this._state, { title: "If specified, this custom legendary action intro text will override the default.", placeholder: "If left blank, defaults to a generic intro.", }, "legendaryHeader", ).appendTo(abilTab.$wrpTab); this.__$getLegendaryActionInput(cb).appendTo(abilTab.$wrpTab); this.__$getLegendaryGroupInput(cb).appendTo(abilTab.$wrpTab); BuilderUi.$getStateIptEntries("Mythic Action Intro", cb, this._state, {}, "mythicHeader").appendTo(abilTab.$wrpTab); this.__$getMythicActionInput(cb).appendTo(abilTab.$wrpTab); this.__$getVariantInput(cb).appendTo(abilTab.$wrpTab); // FLAVOR/MISC this.__$getTokenInput(cb).appendTo(miscTab.$wrpTab); this.$getFluffInput(cb).appendTo(miscTab.$wrpTab); this.__$getEnvironmentInput(cb).appendTo(miscTab.$wrpTab); BuilderUi.$getStateIptStringArray( "Group", cb, this._state, { shortName: "Group", title: "The family this creature belongs to, e.g. 'Modrons' in the case of a Duodrone.", }, "group", ).appendTo(miscTab.$wrpTab); this.__$getSoundClipInput(cb).appendTo(miscTab.$wrpTab); BuilderUi.$getStateIptEnum( "Dragon Casting Color", cb, this._state, { vals: Renderer.monster.dragonCasterVariant.getAvailableColors() .sort(SortUtil.ascSortLower), fnDisplay: (abv) => abv.toTitleCase(), type: "string", }, "dragonCastingColor", ).appendTo(miscTab.$wrpTab); BuilderUi.$getStateIptBoolean("NPC", cb, this._state, {title: "If selected, this creature will be filtered out from the Bestiary list by default."}, "isNpc").appendTo(miscTab.$wrpTab); BuilderUi.$getStateIptBoolean("Familiar", cb, this._state, {title: "If selected, this creature will be included when filtering for 'Familiar' in the Bestiary."}, "familiar").appendTo(miscTab.$wrpTab); BuilderUi.$getStateIptStringArray( "Search Aliases", cb, this._state, { shortName: "Alias", title: "Alternate names for this creature, e.g. 'Illithid' as an alternative for 'Mind Flayer,' which can be searched in the Bestiary.", }, "alias", ).appendTo(miscTab.$wrpTab); // excluded fields: // - otherSources: requires meta support } __$getSizeInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Size", {isMarked: true}); const initial = this._state.size; const setState = () => { this._state.size = rows.map(it => it.$selSize.val()).unique(); cb(); }; const rows = []; const $btnAddSize = $(``) .click(() => { const $tagRow = this.__$getSizeInput__getSizeRow(null, rows, setState); $wrpTagRows.append($tagRow.$wrp); cb(); }); const $initialSizeRows = (initial ? [initial].flat() : [Parser.SZ_MEDIUM]).map(tag => this.__$getSizeInput__getSizeRow(tag, rows, setState)); const $wrpTagRows = $$`
${$initialSizeRows ? $initialSizeRows.map(it => it.$wrp) : ""}
`; $$`
${$wrpTagRows}
${$btnAddSize}
`.appendTo($rowInner); return $row; } __$getSizeInput__getSizeRow (size, sizeRows, setState) { const $selSize = $(``) .val(size || Parser.SZ_MEDIUM) .change(() => { setState(); }); const out = {$selSize}; const $wrpBtnRemove = $(`
`); const $wrp = $$`
${$selSize}${$wrpBtnRemove}
`; Builder.$getBtnRemoveRow(setState, sizeRows, out, $wrp, "Size", {isProtectLast: true}).appendTo($wrpBtnRemove).addClass("ml-2"); out.$wrp = $wrp; sizeRows.push(out); return out; } __$getTypeInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Type", {isMarked: true}); const initial = this._state.type; const initialSwarm = !!initial.swarmSize; const setState = () => { const types = chooseTypeRows .map(rowMeta => rowMeta.cbGetType()); const isSwarm = $selMode.val() === "1"; const validTags = tagRows .map($tr => { const prefix = $tr.$iptPrefix.val().trim(); const tag = $tr.$iptTag.val().trim(); if (!tag) return null; if (prefix) return {tag, prefix}; return tag; }) .filter(Boolean); const note = $iptNote.val().trim(); if (types.length === 1 && !isSwarm && !validTags.length && !note) { this._state.type = types[0]; cb(); return; } const out = { type: types.length === 1 ? types[0] : {choose: types}, }; if (isSwarm) out.swarmSize = $selSwarmSize.val(); if (validTags.length) out.tags = validTags; if (note) out.note = note; this._state.type = out; cb(); }; const $selMode = $(``).val(initialSwarm ? "1" : "0").change(() => { switch ($selMode.val()) { case "0": { $stageSwarm.hideVe(); setState(); break; } case "1": { $stageSwarm.showVe(); setState(); break; } } }).appendTo($rowInner); // region CHOOSE-FROM TYPE CONTROLS const chooseTypeRows = []; const $btnAddChooseType = $(``) .click(() => { const metaTypeRow = this.__$getTypeInput__getChooseTypeRow(null, chooseTypeRows, setState); $wrpChooseTypeRows.append(metaTypeRow.$wrp); }); const initialChooseTypeRowsMetas = initial.type?.choose ? initial.type.choose.map(type => this.__$getTypeInput__getChooseTypeRow(type, chooseTypeRows, setState)) : [this.__$getTypeInput__getChooseTypeRow(initial.type || initial, chooseTypeRows, setState)]; const $wrpChooseTypeRows = $$`
${initialChooseTypeRowsMetas.map(it => it.$wrp)}
`; const $stageType = $$`
${$wrpChooseTypeRows}
${$btnAddChooseType}
`.appendTo($rowInner); // endregion // region TAG CONTROLS const tagRows = []; const $btnAddTag = $(``) .click(() => { const $tagRow = this.__$getTypeInput__getTagRow(null, tagRows, setState); $wrpTagRows.append($tagRow.$wrp); }); const $initialTagRows = initial.tags ? initial.tags.map(tag => this.__$getTypeInput__getTagRow(tag, tagRows, setState)) : null; const $wrpTagRows = $$`
${$initialTagRows ? $initialTagRows.map(it => it.$wrp) : ""}
`; const $stageTags = $$`
${$wrpTagRows}
${$btnAddTag}
`.appendTo($rowInner); // endregion // region SWARM CONTROLS const $selSwarmSize = $(``) .change(() => { this._state.type.swarmSize = $selSwarmSize.val(); cb(); }); const $stageSwarm = $$`
${$selSwarmSize}
`.appendTo($rowInner).toggleVe(initialSwarm); initialSwarm && $selSwarmSize.val(initial.swarmSize); // endregion // region NOTE CONTROLS const $iptNote = $(``) .val(initial.note || "") .change(() => { setState(); }); $$`
Type Note${$iptNote}
` .appendTo($rowInner); // endregion return $row; } __$getTypeInput__getChooseTypeRow (type, chooseTypeRows, setState) { const isInitialCustom = type && !Parser.MON_TYPES.includes(type); const $selType = $(``) .change(() => { setState(); }); if (!isInitialCustom) $selType.val(type || Parser.TP_HUMANOID); const $iptTypeCustom = $(``) .on("change", () => { setState(); }); if (isInitialCustom) $selType.val(type || ""); const $cbIsCustomType = $(``) .on("change", () => { renderIsCustom(); setState(); }) .prop("checked", isInitialCustom); const renderIsCustom = () => { const isChecked = $cbIsCustomType.prop("checked"); $iptTypeCustom.toggleVe(isChecked); $selType.toggleVe(!isChecked); }; renderIsCustom(); const cbGetType = () => { if ($cbIsCustomType.prop("checked")) return $iptTypeCustom.val(); return $selType.val(); }; const $btnRemove = $(``) .click(() => { chooseTypeRows.splice(chooseTypeRows.indexOf(out), 1); $wrp.empty().remove(); setState(); }); const $wrp = $$`
${$selType} ${$iptTypeCustom} ${$btnRemove}
`; const out = {$wrp, cbGetType}; chooseTypeRows.push(out); return out; } __$getTypeInput__getTagRow (tag, tagRows, setState) { const $iptPrefix = $(``) .change(() => { $iptTag.removeClass("form-control--error"); if ($iptTag.val().trim().length || !$iptPrefix.val().trim().length) setState(); else $iptTag.addClass("form-control--error"); }); if (tag && tag.prefix) $iptPrefix.val(tag.prefix); const $iptTag = $(``) .change(() => { $iptTag.removeClass("form-control--error"); setState(); }); if (tag) $iptTag.val(tag.tag || tag); const $btnAddGeneric = $(``) .click(async () => { const tag = await InputUiUtil.pGetUserString({ title: "Enter a Tag", autocomplete: this._bestiaryTypeTags, }); if (tag != null) { $iptTag.val(tag); setState(); } }); const $btnRemove = $(``) .click(() => { tagRows.splice(tagRows.indexOf(out), 1); $wrp.empty().remove(); setState(); }); const $wrp = $$`
${$iptPrefix}${$iptTag}${$btnAddGeneric}${$btnRemove}
`; const out = {$wrp, $iptPrefix, $iptTag}; tagRows.push(out); return out; } __$getShortNameInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Short Name", {isMarked: true, title: "If not supplied, this will be generated from the creature's full name. Used in Legendary Action header text."}); const initialMode = this._state.shortName === true ? "1" : "0"; const setState = mode => { switch (mode) { case 0: { const val = $iptCustom.val().trim(); if (val) this._state.shortName = val; else delete this._state.shortName; break; } case 1: { if ($cbFullName.prop("checked")) this._state.shortName = true; else delete this._state.shortName; break; } } cb(); }; const $selMode = $(``) .change(() => { switch ($selMode.val()) { case "0": { $stageCustom.showVe(); $stageMatchesName.hideVe(); setState(0); break; } case "1": { $stageCustom.hideVe(); $stageMatchesName.showVe(); setState(1); break; } } }) .appendTo($rowInner) .val(initialMode); const $iptCustom = $(``) .change(() => setState(0)) .val(this._state.shortName && this._state.shortName !== true ? this._state.shortName : null); const $stageCustom = $$`
${$iptCustom}
` .toggleVe(initialMode === "0") .appendTo($rowInner); const $cbFullName = $(``) .change(() => setState(1)) .prop("checked", this._state.shortName === true); const $stageMatchesName = $$`` .toggleVe(initialMode === "1") .appendTo($rowInner); return $row; } __$getAlignmentInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Alignment", {isMarked: true}); const doUpdateState = () => { const raw = alignmentRows.map(row => row.getAlignment()); if (raw.some(it => it && (it.special != null || it.alignment !== undefined)) || raw.length > 1) { this._state.alignment = raw.map(it => { if (it && (it.special != null || it.alignment)) return it; else return {alignment: it}; }); } else this._state.alignment = raw[0]; cb(); }; const alignmentRows = []; const $wrpRows = $(`
`).appendTo($rowInner); if ((this._state.alignment && this._state.alignment.some(it => it && (it.special != null || it.alignment !== undefined))) || !~CreatureBuilder.__$getAlignmentInput__getAlignmentIx(this._state.alignment)) { this._state.alignment.forEach(alignment => CreatureBuilder.__$getAlignmentInput__getAlignmentRow(doUpdateState, alignmentRows, alignment).$wrp.appendTo($wrpRows)); } else { CreatureBuilder.__$getAlignmentInput__getAlignmentRow(doUpdateState, alignmentRows, this._state.alignment).$wrp.appendTo($wrpRows); } const $wrpBtnAdd = $(`
`).appendTo($rowInner); $(``) .appendTo($wrpBtnAdd) .click(() => { CreatureBuilder.__$getAlignmentInput__getAlignmentRow(doUpdateState, alignmentRows).$wrp.appendTo($wrpRows); doUpdateState(); }); return $row; } static __$getAlignmentInput__getAlignmentRow (doUpdateState, alignmentRows, alignment) { const initialMode = alignment && alignment.chance ? "1" : alignment && alignment.special ? "2" : (alignment === null || (alignment && alignment.alignment === null)) ? "3" : "0"; const getAlignment = () => { switch ($selMode.val()) { case "0": { return [...CreatureBuilder._ALIGNMENTS[$selAlign.val()]]; } case "1": { const out = {alignment: [...CreatureBuilder._ALIGNMENTS[$selAlign.val()]]}; if ($iptChance.val().trim()) out.chance = UiUtil.strToInt($iptChance.val(), 0, {min: 0, max: 100}); if ($iptNote.val().trim()) out.note = $iptNote.val().trim(); return out; } case "2": { const specials = $iptSpecial.val().trim().split(",").map(it => it.trim()).filter(Boolean); return specials.length ? specials.map(it => ({special: it})) : {special: ""}; } case "3": { return null; } } }; const $selMode = $(``).val(initialMode).change(() => { switch ($selMode.val()) { case "0": { $stageSingle.showVe(); $stageMultiple.hideVe(); $stageSpecial.hideVe(); doUpdateState(); break; } case "1": { $stageSingle.showVe(); $stageMultiple.showVe(); $stageSpecial.hideVe(); doUpdateState(); break; } case "2": { $stageSingle.hideVe(); $stageMultiple.hideVe(); $stageSpecial.showVe(); doUpdateState(); break; } case "3": { $stageSingle.hideVe(); $stageMultiple.hideVe(); $stageSpecial.hideVe(); doUpdateState(); break; } } }); // SINGLE CONTROLS ("multiple" also uses these) const $selAlign = $(``) .change(() => doUpdateState()); const $stageSingle = $$`
${$selAlign}
`.toggleVe(initialMode === "0" || initialMode === "1"); initialMode === "0" && alignment && $selAlign.val(CreatureBuilder.__$getAlignmentInput__getAlignmentIx(alignment.alignment || alignment)); initialMode === "1" && alignment && $selAlign.val(CreatureBuilder.__$getAlignmentInput__getAlignmentIx(alignment.alignment)); // MULTIPLE CONTROLS const $iptChance = $(``) .change(() => doUpdateState()); const $iptNote = $(``) .change(() => doUpdateState()); const $stageMultiple = $$`
${$iptChance}%
(${$iptNote})
`.toggleVe(initialMode === "1"); if (initialMode === "1" && alignment) { $iptChance.val(alignment.chance); $iptNote.val(alignment.note); } // SPECIAL CONTROLS const $iptSpecial = $(``) .change(() => doUpdateState()); const $stageSpecial = $$`
${$iptSpecial}
`.toggleVe(initialMode === "2"); initialMode === "2" && alignment && $iptSpecial.val(alignment.special); const $btnRemove = $(``) .click(() => { alignmentRows.splice(alignmentRows.indexOf(out), 1); $wrp.empty().remove(); doUpdateState(); }); const $wrp = $$`
${$selMode}${$stageSingle}${$stageMultiple}${$stageSpecial}${$$`
${$btnRemove}
`}
`; const out = {$wrp, getAlignment}; alignmentRows.push(out); return out; } static __$getAlignmentInput__getAlignmentIx (alignment) { return CreatureBuilder._ALIGNMENTS.findIndex(it => CollectionUtil.setEq(new Set(it), new Set(alignment))); } __$getAcInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Armor Class", {isMarked: true}); const doUpdateState = () => { this._state.ac = acRows.map($row => $row.getAc()); cb(); }; const acRows = []; const $wrpRows = $(`
`).appendTo($rowInner); this._state.ac.forEach(ac => CreatureBuilder.__$getAcInput__getAcRow(ac, acRows, doUpdateState).$wrp.appendTo($wrpRows)); const $wrpBtnAdd = $(`
`).appendTo($rowInner); $(``) .appendTo($wrpBtnAdd) .click(() => { CreatureBuilder.__$getAcInput__getAcRow(null, acRows, doUpdateState).$wrp.appendTo($wrpRows); doUpdateState(); }); return $row; } static __$getAcInput__getAcRow (ac, acRows, doUpdateState) { const initialMode = ac && ac.special ? "2" : ac && ac.from ? "1" : "0"; const getAc = () => { const acValRaw = UiUtil.strToInt($iptAc.val(), 10, {fallbackOnNaN: 10}); const acVal = isNaN(acValRaw) ? 10 : acValRaw; const condition = $iptCond.val().trim(); const braces = $cbBraces.prop("checked"); const getBaseAC = () => { if (condition) { const out = { ac: acVal, condition, }; if (braces) out.braces = true; return out; } else return acVal; }; switch ($selMode.val()) { case "0": { return getBaseAC(); } case "1": { const froms = fromRows.map(it => it.getAcFrom()).filter(Boolean); if (froms.length) { const out = { ac: acVal, from: froms, }; if (condition) out.condition = condition; if (braces) out.braces = true; return out; } else return getBaseAC(); } case "2": { return {special: $iptSpecial.val()}; } } }; const $selMode = $(``).val(initialMode).change(() => { switch ($selMode.val()) { case "0": { $stageFrom.hideVe(); $iptAc.showVe(); $iptSpecial.hideVe(); doUpdateState(); break; } case "1": { $stageFrom.showVe(); $iptAc.showVe(); $iptSpecial.hideVe(); if (!fromRows.length) CreatureBuilder.__$getAcInput__getFromRow(null, fromRows, doUpdateState).$wrpFrom.appendTo($wrpFromRows); doUpdateState(); break; } case "2": { $stageFrom.hideVe(); $iptAc.hideVe(); $iptSpecial.showVe(); doUpdateState(); break; } } }); const $iptAc = $(``) .val(ac && ac.special == null ? ac.ac || ac : 10) .change(() => doUpdateState()) .toggleVe(initialMode !== "2"); const $iptSpecial = $(``) .val(ac && ac.special ? ac.special : null) .change(() => doUpdateState()) .toggleVe(initialMode === "2"); const $iptCond = $(``) .change(() => doUpdateState()); if (ac && ac.condition) $iptCond.val(ac.condition); const $cbBraces = $(``) .change(() => doUpdateState()); if (ac && ac.braces) $cbBraces.prop("checked", ac.braces); // "FROM" CONTROLS const fromRows = []; const $wrpFromRows = $(`
`); if (ac && ac.from) ac.from.forEach(f => CreatureBuilder.__$getAcInput__getFromRow(f, fromRows, doUpdateState).$wrpFrom.appendTo($wrpFromRows)); const $btnAddFrom = $(``) .click(() => { CreatureBuilder.__$getAcInput__getFromRow(null, fromRows, doUpdateState).$wrpFrom.appendTo($wrpFromRows); doUpdateState(); }); const $stageFrom = $$`
${$wrpFromRows} ${$$`
${$btnAddFrom}
`}
`.toggleVe(initialMode === "1"); // REMOVE CONTROLS const $btnRemove = $(``) .click(() => { acRows.splice(acRows.indexOf(out), 1); $wrp.empty().remove(); doUpdateState(); }); const $wrp = $$`
${$iptAc}${$iptSpecial}${$selMode}
${$$`
${$stageFrom}
`}
Condition${$iptCond}
${$$`
${$btnRemove}
`}
`; const out = {$wrp, getAc}; acRows.push(out); return out; } static __$getAcInput__getFromRow (from, fromRows, doUpdateState) { const getAcFrom = () => $iptFrom.val().trim(); const $iptFrom = $(``) .change(() => doUpdateState()); if (from) $iptFrom.val(from); const menu = ContextUtil.getMenu(Object.keys(CreatureBuilder._AC_COMMON).map(k => { return new ContextUtil.Action( k, () => { $iptFrom.val(CreatureBuilder._AC_COMMON[k]); doUpdateState(); }, ); })); const $btnCommon = $(``) .click(evt => ContextUtil.pOpenMenu(evt, menu)); const $btnSearchItem = $(``) .click(() => { const searchWidget = new SearchWidget( {Item: SearchWidget.CONTENT_INDICES.Item}, (doc) => { $iptFrom.val(`{@item ${doc.n}${doc.s !== Parser.SRC_DMG ? `|${doc.s}` : ""}}`.toLowerCase()); doUpdateState(); doClose(); }, {defaultCategory: "Item"}, ); const {$modalInner, doClose} = UiUtil.getShowModal({ title: "Select Item", cbClose: () => searchWidget.$wrpSearch.detach(), // guarantee survival of rendered element }); $modalInner.append(searchWidget.$wrpSearch); searchWidget.doFocus(); }); const $btnRemove = $(``) .click(() => { fromRows.splice(fromRows.indexOf(outFrom), 1); $wrpFrom.empty().remove(); ContextUtil.deleteMenu(menu); doUpdateState(); }); const $wrpFrom = $$`
${$iptFrom}${$btnCommon}${$btnSearchItem}${$btnRemove}
`; const outFrom = {$wrpFrom, getAcFrom}; fromRows.push(outFrom); return outFrom; } __$getHpInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Hit Points", {isMarked: true}); const initialMode = (() => { if (this._state.hp.special != null) return "2"; else { const parts = CreatureBuilder.__$getHpInput__getFormulaParts(this._state.hp.formula); return parts != null ? "0" : "1"; } })(); const _getSimpleFormula = () => { const mod = UiUtil.strToInt($iptSimpleMod.val()); return `${$selSimpleNum.val()}d${$selSimpleFace.val()}${mod === 0 ? "" : UiUtil.intToBonus(mod)}`; }; const doUpdateState = () => { switch ($selMode.val()) { case "0": { this._state.hp = { formula: _getSimpleFormula(), average: UiUtil.strToInt($iptSimpleAverage.val()), }; break; } case "1": { this._state.hp = { formula: $iptComplexFormula.val(), average: UiUtil.strToInt($iptComplexAverage.val()), }; break; } case "2": { this._state.hp = {special: $iptSpecial.val()}; break; } } cb(); }; const doUpdateVisibleStage = () => { switch ($selMode.val()) { case "0": $wrpSimpleFormula.showVe(); $wrpComplexFormula.hideVe(); $wrpSpecial.hideVe(); break; case "1": $wrpSimpleFormula.hideVe(); $wrpComplexFormula.showVe(); $wrpSpecial.hideVe(); break; case "2": $wrpSimpleFormula.hideVe(); $wrpComplexFormula.hideVe(); $wrpSpecial.showVe(); break; } }; const $selMode = $(``) .appendTo($rowInner) .val(initialMode) .change(() => { doUpdateVisibleStage(); doUpdateState(); }); // SIMPLE FORMULA STAGE const conHook = () => { if (!this._meta.autoCalc.hpModifier) return; const num = Number($selSimpleNum.val()); const mod = Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, "con", {isDefaultTen: true})); const total = num * mod; $iptSimpleMod.val(total ?? null); hpSimpleAverageHook(); doUpdateState(); }; this._addHook("state", "con", conHook); const hpSimpleAverageHook = () => { // no proxy required, due to being inside a child object if (this._meta.autoCalc.hpAverageSimple) { const avg = Renderer.dice.parseAverage(_getSimpleFormula()); if (avg != null) $iptSimpleAverage.val(Math.floor(avg)); } }; const $selSimpleNum = $(``) .change(() => { conHook(); hpSimpleAverageHook(); doUpdateState(); }); const $selSimpleFace = $(``) .change(() => { hpSimpleAverageHook(); doUpdateState(); }); const $iptSimpleMod = $(``) .change(() => { if (this._meta.autoCalc.hpModifier) { this._meta.autoCalc.hpModifier = false; $btnAutoSimpleFormula.removeClass("active"); } hpSimpleAverageHook(); doUpdateState(); }); const $btnAutoSimpleFormula = $(``) .click(() => { if (this._meta.autoCalc.hpModifier) { this._meta.autoCalc.hpModifier = false; this.doUiSave(); } else { this._meta.autoCalc.hpModifier = true; conHook(); } $btnAutoSimpleFormula.toggleClass("active", this._meta.autoCalc.hpModifier); doUpdateState(); }); const $iptSimpleAverage = $(``) .change(() => { this._meta.autoCalc.hpAverageSimple = false; doUpdateState(); }); const $btnAutoSimpleAverage = $(``) .click(() => { if (this._meta.autoCalc.hpAverageSimple) { this._meta.autoCalc.hpAverageSimple = false; this.doUiSave(); } else { this._meta.autoCalc.hpAverageSimple = true; hpSimpleAverageHook(); } $btnAutoSimpleAverage.toggleClass("active", this._meta.autoCalc.hpAverageSimple); doUpdateState(); }); const $wrpSimpleFormula = $$`
Formula ${$selSimpleNum} d ${$selSimpleFace} + ${$iptSimpleMod} ${$btnAutoSimpleFormula}
Average${$iptSimpleAverage}${$btnAutoSimpleAverage}
`.toggleVe(initialMode === "1").appendTo($rowInner); if (initialMode === "0") { const formulaParts = CreatureBuilder.__$getHpInput__getFormulaParts(this._state.hp.formula); $selSimpleNum.val(`${formulaParts.hdNum}`); $selSimpleFace.val(`${formulaParts.hdFaces}`); if (formulaParts.mod != null) $iptSimpleMod.val(formulaParts.mod); $iptSimpleAverage.val(this._state.hp.average); } // COMPLEX FORMULA STAGE const hpComplexAverageHook = () => { // no proxy required, due to being inside a child object if (this._meta.autoCalc.hpAverageComplex) { const avg = Renderer.dice.parseAverage($iptComplexFormula.val()); if (avg != null) $iptComplexAverage.val(Math.floor(avg)); } }; const $iptComplexFormula = $(``) .change(() => { hpComplexAverageHook(); doUpdateState(); }); const $iptComplexAverage = $(``) .change(() => { this._meta.autoCalc.hpAverageComplex = false; doUpdateState(); }); const $btnAutoComplexAverage = $(``) .click(() => { if (this._meta.autoCalc.hpAverageComplex) { this._meta.autoCalc.hpAverageComplex = false; this.doUiSave(); } else { this._meta.autoCalc.hpAverageComplex = true; hpComplexAverageHook(); } $btnAutoComplexAverage.toggleClass("active", this._meta.autoCalc.hpAverageComplex); doUpdateState(); }); const $wrpComplexFormula = $$`
Formula${$iptComplexFormula}
Average${$iptComplexAverage}${$btnAutoComplexAverage}
`.toggleVe(initialMode === "0").appendTo($rowInner); if (initialMode === "1") { $iptComplexFormula.val(this._state.hp.formula); $iptComplexAverage.val(this._state.hp.average); } // SPECIAL STAGE const $iptSpecial = $(``) .change(() => doUpdateState()); const $wrpSpecial = $$`
${$iptSpecial}
`.toggleVe(initialMode === "2").appendTo($rowInner); if (initialMode === "2") $iptSpecial.val(this._state.hp.special); doUpdateVisibleStage(); return $row; } static __$getHpInput__getFormulaParts (formula) { formula = formula .replace(/\s+/g, "") .replace(/[\u2012\u2013\u2014]/g, "-"); const m = /^(\d*)d(\d+)([-+]\d+)?$/.exec(formula); if (!m) return null; const hdNum = m[1] ? Number(m[1]) : 1; if (hdNum <= 0 || hdNum > 50) return null; // if it's e.g. 0d10, consider invalid. Cap at 50 HD const hdFaces = Number(m[2]); if (!Renderer.dice.DICE.includes(hdFaces)) return null; // if it's a non-standard dice face (e.g. 1d7) const out = {hdNum, hdFaces}; if (m[3]) out.mod = Number(m[3]); return out; } __$getSpeedInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Speed", {isMarked: true}); const $getRow = (name, prop) => { const doUpdateProp = () => { const speedRaw = $iptSpeed.val().trim(); if (!speedRaw) { delete this._state.speed[prop]; if (prop === "fly") delete this._state.speed.canHover; } else { const speed = UiUtil.strToInt(speedRaw); const condition = $iptCond.val().trim(); this._state.speed[prop] = (condition ? {number: speed, condition: condition} : speed); if (prop === "fly") { this._state.speed.canHover = !!(condition && /(^|[^a-zA-Z])hover([^a-zA-Z]|$)/i.exec(condition)); if (!this._state.speed.canHover) delete this._state.speed.canHover; } } cb(); }; const $iptSpeed = $(``) .change(() => doUpdateProp()); const $iptCond = $(``) .change(() => doUpdateProp()); const initial = this._state.speed[prop]; if (initial != null) { if (initial.condition != null) { $iptSpeed.val(initial.number); $iptCond.val(initial.condition); } else $iptSpeed.val(initial); } return $$`
${name}
${$iptSpeed}ft.${$iptCond}
`; }; $$`
${$getRow("Walk", "walk")} ${$getRow("Burrow", "burrow")} ${$getRow("Climb", "climb")} ${$getRow("Fly", "fly")} ${$getRow("Swim", "swim")}
`.appendTo($rowInner); return $row; } __$getAbilityScoreInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Ability Scores", {isMarked: true, isRow: true}); const $getRow = (name, prop) => { const valInitial = this._state[prop] != null && typeof this._state[prop] !== "number" ? this._state[prop].special : this._state[prop]; const $iptAbil = $(``) .val(valInitial) .change(() => { const val = $iptAbil.val().trim(); if (!val) { delete this._state[prop]; return cb(); } if (isNaN(val)) { this._state[prop] = {special: val}; return cb(); } this._state[prop] = UiUtil.strToInt(val); cb(); }); return $$`
${prop.toUpperCase()} ${$iptAbil}
`; }; Parser.ABIL_ABVS.forEach(abv => $getRow(Parser.attAbvToFull(abv), abv).appendTo($rowInner)); return $row; } __$getSaveInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Saving Throws", {isMarked: true, isRow: true}); const $getRow = (name, prop) => { const $iptVal = $(``) .change(() => { $btnProf.removeClass("active"); delete this._meta.profSave[prop]; this.__$getSaveSkillInput__handleValChange(cb, "save", $iptVal, prop); }); const _setFromAbility = () => { const total = Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, prop, {isDefaultTen: true})) + this._getProfBonus(); (this._state.save = this._state.save || {})[prop] = total < 0 ? `${total}` : `+${total}`; $iptVal.val(total); cb(); }; const $btnProf = $(``) .click(() => { if (this._meta.profSave[prop]) { delete this._meta.profSave[prop]; $iptVal.val(""); this.__$getSaveSkillInput__handleValChange(cb, "save", $iptVal, prop); } else { this._meta.profSave[prop] = 1; hook(); } $btnProf.toggleClass("active", this._meta.profSave[prop] === 1); }); if (this._meta.profSave[prop]) $btnProf.addClass("active"); if ((this._state.save || {})[prop]) $iptVal.val(`${this._state.save[prop]}`.replace(/^\+/, "")); // remove leading plus sign const hook = () => { if (this._meta.profSave[prop] === 1) _setFromAbility(); }; this._addHook("state", prop, hook); this._addHook("meta", "profBonus", hook); return $$`
${prop.toUpperCase()} ${$iptVal}${$btnProf}
`; }; Parser.ABIL_ABVS.forEach(abv => $getRow(Parser.attAbvToFull(abv), abv).appendTo($rowInner)); return $row; } __$getSkillInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Skills", {isMarked: true}); const $getRow = (name, prop) => { const abilProp = Parser.skillToAbilityAbv(prop); const $iptVal = $(``) .change(() => { if (this._meta.profSkill[prop]) { $btnProf.removeClass("active"); $btnExpert.removeClass("active"); } delete this._meta.profSkill[prop]; this.__$getSaveSkillInput__handleValChange(cb, "skill", $iptVal, prop); }); const _setFromAbility = (isExpert) => { const total = Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, abilProp, {isDefaultTen: true})) + (this._getProfBonus() * (2 - !isExpert)); const nextSkills = {...(this._state.skill || {})}; // regenerate the object to allow hooks to fire nextSkills[prop] = total < 0 ? `${total}` : `+${total}`; this._state.skill = nextSkills; $iptVal.val(total); cb(); }; const _handleButtonPress = (isExpert) => { if (isExpert) { if (this._meta.profSkill[prop] === 2) { delete this._meta.profSkill[prop]; $iptVal.val(""); this.__$getSaveSkillInput__handleValChange(cb, "skill", $iptVal, prop); } else { this._meta.profSkill[prop] = 2; hook(); } $btnProf.removeClass("active"); $btnExpert.toggleClass("active", this._meta.profSkill[prop] === 2); } else { if (this._meta.profSkill[prop] === 1) { delete this._meta.profSkill[prop]; $iptVal.val(""); this.__$getSaveSkillInput__handleValChange(cb, "skill", $iptVal, prop); } else { this._meta.profSkill[prop] = 1; hook(); } $btnProf.toggleClass("active", this._meta.profSkill[prop] === 1); $btnExpert.removeClass("active"); } }; const $btnProf = $(``) .click(() => _handleButtonPress()); if (this._meta.profSkill[prop] === 1) $btnProf.addClass("active"); const $btnExpert = $(``) .click(() => _handleButtonPress(true)); if (this._meta.profSkill[prop] === 2) $btnExpert.addClass("active"); if ((this._state.skill || {})[prop]) $iptVal.val(`${this._state.skill[prop]}`.replace(/^\+/, "")); // remove leading plus sign const hook = () => { if (this._meta.profSkill[prop] === 1) _setFromAbility(); else if (this._meta.profSkill[prop] === 2) _setFromAbility(true); }; this._addHook("state", abilProp, hook); this._addHook("meta", "profBonus", hook); return $$`
${name}
(${Parser.skillToAbilityAbv(prop).toUpperCase()})
${$iptVal}${$btnProf}${$btnExpert}
`; }; Object.keys(Parser.SKILL_TO_ATB_ABV).sort(SortUtil.ascSort).forEach(skill => $getRow(skill.toTitleCase(), skill).appendTo($rowInner)); return $row; } __$getSaveSkillInput__handleValChange (cb, mode, $iptVal, prop) { // ensure to overwrite the entire object, so that any hooks trigger const raw = $iptVal.val(); if (raw && raw.trim()) { const num = UiUtil.strToInt(raw); const nextState = {...(this._state[mode] || {})}; nextState[prop] = num < 0 ? `${num}` : `+${num}`; this._state[mode] = nextState; } else { if (this._state[mode]) { const nextState = {...this._state[mode]}; delete nextState[prop]; if (Object.keys(nextState).length === 0) delete this._state[mode]; else this._state[mode] = nextState; } } cb(); } __$getPassivePerceptionInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Passive Perception"); const hook = () => { if (this._meta.autoCalc.passivePerception) { const pp = Math.round((() => { if (this._state.skill && this._state.skill.perception && this._state.skill.perception.trim()) return Number(this._state.skill.perception); else return Parser.getAbilityModNumber(Renderer.monster.getSafeAbilityScore(this._state, "wis", {isDefaultTen: true})); })() + 10); $iptPerception.val(pp); this._state.passive = pp; cb(); } }; this._addHook("state", "wis", hook); this._addHook("state", "skill", hook); const $iptPerception = $(``) .change(() => { if (this._meta.autoCalc.passivePerception) { $btnAuto.removeClass("active"); this._meta.autoCalc.passivePerception = false; } this._state.passive = UiUtil.strToInt($iptPerception.val()); cb(); }) .val(this._state.passive || 0); const $btnAuto = $(``) .click(() => { if (this._meta.autoCalc.passivePerception) { delete this._meta.autoCalc.passivePerception; this.doUiSave(); // save meta-state } else { this._meta.autoCalc.passivePerception = true; hook(); } $btnAuto.toggleClass("active", this._meta.autoCalc.passivePerception); cb(); }); $$`
${$iptPerception}${$btnAuto}
`.appendTo($rowInner); return $row; } __$getVulnerableInput (cb) { return this.__$getDefencesInput(cb, "Damage Vulnerabilities", "Vulnerability", "vulnerable"); } __$getResistInput (cb) { return this.__$getDefencesInput(cb, "Damage Resistances", "Resistance", "resist"); } __$getImmuneInput (cb) { return this.__$getDefencesInput(cb, "Damage Immunities", "Immunity", "immune"); } __$getCondImmuneInput (cb) { return this.__$getDefencesInput(cb, "Condition Immunities", "Immunity", "conditionImmune"); } __$getDefencesInput (cb, rowName, shortName, prop) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple(rowName, {isMarked: true}); const groups = []; const $wrpGroups = $(`
`).appendTo($rowInner); const $wrpControls = $(`
`).appendTo($rowInner); const doUpdateState = () => { const out = groups.map(it => it.getState()); if (out.length) { // flatten a single group if there's no meta-information if (out.length === 1 && !out[0].note && !out[0].preNote) this._state[prop] = [...out[0][prop]]; else this._state[prop] = out; } else delete this._state[prop]; cb(); }; const doAddGroup = data => { const group = CreatureBuilder.__$getDefencesInput__getNodeGroup(shortName, prop, groups, doUpdateState, 0, data); groups.push(group); group.$ele.appendTo($wrpGroups); }; const $btnAddGroup = $(``) .appendTo($wrpControls) .click(() => doAddGroup()); if (this._state[prop]) { // convert flat arrays into wrapped objects if (this._state[prop].some(it => it[prop] == null)) doAddGroup({[prop]: this._state[prop]}); else this._state[prop].forEach(dmgType => doAddGroup(dmgType)); } return $row; } static __$getDefencesInput__getNodeGroup (shortName, prop, groups, doUpdateState, depth, initial) { const children = []; const getState = () => { const out = { [prop]: children.map(it => it.getState()).filter(Boolean), }; if ($iptNotePre.val().trim()) out.preNote = $iptNotePre.val().trim(); if ($iptNotePost.val().trim()) out.note = $iptNotePost.val().trim(); return out; }; const addChild = (child, doUpdate = true) => { if (child == null) return; children.push(child); children.sort((a, b) => { // sort specials and groups to the bottom, in that order // `.order` ensures no non-deterministic shuffling occurs if ((a.type === "group" || a.type === "special") && a.type === b.type) return b.order - a.order; else if (a.type === "group" && b.type === "special") return 1; else if (a.type === "special" && b.type === "group") return -1; else if (a.type === "group" || a.type === "special") return 1; else if (b.type === "group" || b.type === "special") return -1; else return SortUtil.ascSort(a.type, b.type) || b.order - a.order; }).forEach(child => { child.$ele.detach(); $wrpChildren.append(child.$ele); }); if (doUpdate) doUpdateState(); }; const optionsList = prop === "conditionImmune" ? Parser.CONDITIONS : Parser.DMG_TYPES; const menu = ContextUtil.getMenu([...optionsList, null, "Special"].map((it, i) => { if (it == null) return null; return new ContextUtil.Action( it.toTitleCase(), () => { const child = (() => { const alreadyExists = (type) => children.some(ch => ch.type === type); if (i < optionsList.length) { if (alreadyExists(optionsList[i])) return null; return CreatureBuilder.__$getDefencesInput__getNodeItem(shortName, children, doUpdateState, optionsList[i]); } else { // "Special" if (alreadyExists("special")) return null; return CreatureBuilder.__$getDefencesInput__getNodeItem(shortName, children, doUpdateState, "special"); } })(); addChild(child); }, ); })); const $btnAddChild = $(``) .click((evt) => ContextUtil.pOpenMenu(evt, menu)); const $btnAddChildGroup = $(``) .click(() => addChild(CreatureBuilder.__$getDefencesInput__getNodeGroup(shortName, prop, children, doUpdateState, depth + 1))); const $iptNotePre = $(``) .change(() => doUpdateState()); const $iptNotePost = $(``) .change(() => doUpdateState()); const $btnRemove = $(``) .click(() => { groups.splice(groups.indexOf(out), 1); $ele.remove(); doUpdateState(); }); const $wrpChildren = $(`
`); const $wrpControls = $$`
${$btnAddChild}${$btnAddChildGroup}${$iptNotePre}${$iptNotePost}${$btnRemove}
`; const $ele = (() => { const $base = $$`
${$wrpControls}${$wrpChildren}
`; if (!depth) return $base; else return $$`
${$base}
`; })(); if (initial) { $iptNotePre.val(initial.preNote || ""); $iptNotePost.val(initial.note || ""); initial[prop].forEach(dmgType => { if (typeof dmgType === "string") addChild(CreatureBuilder.__$getDefencesInput__getNodeItem(shortName, children, doUpdateState, dmgType), false); else if (dmgType.special != null) addChild(CreatureBuilder.__$getDefencesInput__getNodeItem(shortName, children, doUpdateState, "special", dmgType.special), false); else addChild(CreatureBuilder.__$getDefencesInput__getNodeGroup(shortName, prop, children, doUpdateState, depth + 1, dmgType), false); }); } const out = {getState, $ele, type: "group", order: CreatureBuilder._rowSortOrder++}; return out; } static __$getDefencesInput__getNodeItem (shortName, children, doUpdateState, type, value) { const $btnRemove = $(``) .click(() => { children.splice(children.indexOf(out), 1); $ele.remove(); doUpdateState(); }); const {$ele, getState} = (() => { switch (type) { case "special": { const $iptSpecial = $(``) .change(() => doUpdateState()); if (value != null) $iptSpecial.val(value); return { $ele: $$`
${$iptSpecial}${$btnRemove}
`, getState: () => { const raw = $iptSpecial.val().trim(); if (raw) return {special: raw}; return null; }, }; } default: { return { $ele: $$`
• ${type.uppercaseFirst()}${$btnRemove}
`, getState: () => type, }; } } })(); const out = {$ele, getState, type, order: CreatureBuilder._rowSortOrder++}; return out; } __$getSenseInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Senses"); const doUpdateState = () => { const raw = $iptSenses.val().trim(); if (!raw) delete this._state.senses; else this._state.senses = raw.split(StrUtil.COMMA_SPACE_NOT_IN_PARENTHESES_REGEX); cb(); }; const $iptSenses = $(``) .change(() => doUpdateState()); if (this._state.senses && this._state.senses.length) $iptSenses.val(this._state.senses.join(", ")); const menu = ContextUtil.getMenu( Parser.SENSES .map(({name: sense}) => { return new ContextUtil.Action( sense.uppercaseFirst(), async () => { const feet = await InputUiUtil.pGetUserNumber({min: 0, int: true, title: "Enter the Number of Feet"}); if (feet == null) return; const curr = $iptSenses.val().trim(); const toAdd = `${sense} ${feet} ft.`; $iptSenses.val(curr ? `${curr}, ${toAdd}` : toAdd); doUpdateState(); }, ); }), ); const $btnAddGeneric = $(``) .click((evt) => ContextUtil.pOpenMenu(evt, menu)); const $btnSort = BuilderUi.$getSplitCommasSortButton($iptSenses, doUpdateState); $$`
${$iptSenses}${$btnAddGeneric}${$btnSort}
`.appendTo($rowInner); return $row; } __$getLanguageInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Languages"); const doUpdateState = () => { const raw = $iptLanguages.val().trim(); if (!raw) delete this._state.languages; else this._state.languages = raw.split(StrUtil.COMMA_SPACE_NOT_IN_PARENTHESES_REGEX); cb(); }; const $iptLanguages = $(``) .change(() => doUpdateState()); if (this._state.languages && this._state.languages.length) $iptLanguages.val(this._state.languages.join(", ")); const availLanguages = Object.entries(Parser.MON_LANGUAGE_TAG_TO_FULL).filter(([k]) => !CreatureBuilder._LANGUAGE_BLOCKLIST.has(k)) .map(([k, v]) => v === "Telepathy" ? "telepathy" : v); // lowercase telepathy const $btnAddGeneric = $(``) .click(async () => { const language = await InputUiUtil.pGetUserString({ title: "Enter a Language", default: "Common", autocomplete: availLanguages, }); if (language != null) { const curr = $iptLanguages.val().trim(); $iptLanguages.val(curr ? `${curr}, ${language}` : language); doUpdateState(); } }); const $btnSort = BuilderUi.$getSplitCommasSortButton($iptLanguages, doUpdateState, {bottom: [/telepathy/i]}); $$`
${$iptLanguages}${$btnAddGeneric}${$btnSort}
`.appendTo($rowInner); return $row; } __$getCrInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Challenge Rating", {isMarked: true}); const initialMode = this._state.cr != null ? this._state.cr.lair ? "1" : this._state.cr.coven ? "2" : ScaleCreature.isCrInScaleRange(this._state) ? "0" : "3" : "4"; const $selMode = $(``).val(initialMode).change(() => { switch ($selMode.val()) { case "0": { $stageBasic.showVe(); $stageLair.hideVe(); $stageCoven.hideVe(); $stageCustom.hideVe(); this._state.cr = $selCr.val(); break; } case "1": { $stageBasic.showVe(); $stageLair.showVe(); $stageCoven.hideVe(); $stageCustom.hideVe(); this._state.cr = { cr: $selCr.val(), lair: $selCrLair.val(), }; break; } case "2": { $stageBasic.showVe(); $stageLair.hideVe(); $stageCoven.showVe(); $stageCustom.hideVe(); this._state.cr = { cr: $selCr.val(), coven: $selCrCoven.val(), }; break; } case "3": { $stageBasic.hideVe(); $stageLair.hideVe(); $stageCoven.hideVe(); $stageCustom.showVe(); compCrCustom.setParentState(this); break; } case "4": { $stageBasic.hideVe(); $stageLair.hideVe(); $stageCoven.hideVe(); $stageCustom.hideVe(); delete this._state.cr; break; } } cb(); }).appendTo($rowInner); // region BASIC CONTROLS const $selCr = $(``) .val(this._state.cr ? (this._state.cr.cr || this._state.cr) : null).change(() => { if ($selMode.val() === "0") this._state.cr = $selCr.val(); else this._state.cr.cr = $selCr.val(); cb(); }); const $stageBasic = $$`
${$selCr}
` .appendTo($rowInner).toggleVe(!["3", "4"].includes(initialMode)); // endregion // region LAIR CONTROLS const $selCrLair = $(``) .change(() => { this._state.cr.lair = $selCrLair.val(); cb(); }); const $stageLair = $$`
While in lair${$selCrLair}
` .appendTo($rowInner).toggleVe(initialMode === "1"); initialMode === "1" && $selCrLair.val(this._state.cr.cr); // endregion // region COVEN CONTROLS const $selCrCoven = $(``) .change(() => { this._state.cr.coven = $selCrCoven.val(); cb(); }); const $stageCoven = $$`
While in coven${$selCrCoven}
` .appendTo($rowInner).toggleVe(initialMode === "2"); initialMode === "2" && $selCrCoven.val(this._state.cr.cr); // endregion // region CUSTOM CONTROLS const compCrCustom = new class extends BaseComponent { renderInputCr () { return ComponentUiUtil.$getIptStr(this, "cr", {autocomplete: Parser.CRS}); } renderInputXp () { return ComponentUiUtil.$getIptInt(this, "xp", 0, {isAllowNull: true}); } setParentState (parent) { if (this._state.cr) { const nxtState = {cr: this._state.cr}; if (this._state.xp != null) nxtState.xp = this._state.xp; parent._state.cr = nxtState; } else delete parent._state.cr; } }(); const $stageCustom = $$`
CR${compCrCustom.renderInputCr()}
XP${compCrCustom.renderInputXp()}
` .appendTo($rowInner).toggleVe(initialMode === "3"); if (initialMode === "3") { compCrCustom._state.cr = this._state.cr.cr ?? this._state.cr; compCrCustom._state.xp = this._state.cr.xp; } compCrCustom._addHookAll("state", () => { compCrCustom.setParentState(this); cb(); }); // endregion return $row; } // this doesn't directly affect state, but is used as a helper for other inputs __$getProfBonusInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Proficiency Bonus"); const hook = () => { // update proficiency bonus input as required if (this._meta.autoCalc.proficiency) { if (this._state.cr == null) { $iptProfBonus.val(0); this._meta.profBonus = 0; } else { const pb = Parser.crToPb(this._state.cr.cr || this._state.cr); $iptProfBonus.val(pb); this._meta.profBonus = pb; } cb(); } }; this._addHook("state", "cr", hook); const $iptProfBonus = $(``) .val(this._getProfBonus()) .change(() => { this._meta.profBonus = UiUtil.strToInt($iptProfBonus.val(), 0, {min: 0}); this._meta.autoCalc.proficiency = false; $iptProfBonus.val(UiUtil.intToBonus(this._meta.profBonus)); cb(); }); const $btnAuto = $(``) .click(() => { if (this._meta.autoCalc.proficiency) { this._meta.autoCalc.proficiency = false; this.doUiSave(); } else { this._meta.autoCalc.proficiency = true; hook(); } $btnAuto.toggleClass("active", this._meta.autoCalc.proficiency); cb(); }); $$`
${$iptProfBonus}${$btnAuto}
`.appendTo($rowInner); return $row; } _getProfBonus () { if (this._meta.profBonus != null) return this._meta.profBonus || 0; else return this._state.cr == null ? 0 : Parser.crToPb(this._state.cr.cr || this._state.cr); } __$getProfNoteInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Proficiency Note", {title: `The value to display as the "Proficiency Bonus" on the statblock. If not specified, the display value is based on the creature's CR.`}); const $iptPbNote = $(``) .val(this._state.pbNote || "") .change(() => { const val = $iptPbNote.val().trim(); if (val) this._state.pbNote = val; else delete this._state.pbNote; cb(); }); $$`
${$iptPbNote}
`.appendTo($rowInner); return $row; } __$getSpellcastingInput (cb) { const [$row, $rowInner] = BuilderUi.getLabelledRowTuple("Spellcasting", {isMarked: true}); const traitRows = []; const $wrpRows = $(`
`).appendTo($rowInner); const $wrpControls = $(`
`).appendTo($rowInner); const $btnAddRow = $(``) .appendTo($wrpControls) .click(() => { doAddTrait(); doUpdateState(); }); const doUpdateState = () => { if (!traitRows.length) delete this._state.spellcasting; else { const spellcastingTraits = traitRows.map(r => r.getState()).filter(Boolean); if (spellcastingTraits.length) this._state.spellcasting = spellcastingTraits; else delete this._state.spellcasting; } cb(); }; const doAddTrait = trait => { const row = CreatureBuilder.__$getSpellcastingInput__getTraitRow(traitRows, doUpdateState, trait); traitRows.push(row); row.$ele.appendTo($wrpRows); }; if (this._state.spellcasting) this._state.spellcasting.forEach(sc => doAddTrait(sc)); return $row; } static __$getSpellcastingInput__getTraitRow (traitRows, doUpdateState, trait) { const getState = () => { const out = { name: $iptName.val().trim(), }; if ($btnToggleHeader.hasClass("active")) out.headerEntries = UiUtil.getTextAsEntries($iptHeader.val()); if (out.headerEntries && !out.headerEntries.length) delete out.headerEntries; if ($btnToggleFooter.hasClass("active")) out.footerEntries = UiUtil.getTextAsEntries($iptFooter.val()); if (out.footerEntries && !out.footerEntries.length) delete out.footerEntries; const deepMerge = (target, k, v) => { const curr = target[k]; if (curr == null) target[k] = v; else { if (typeof v === "object") { if (v instanceof Array) target[k] = curr.concat(v); else Object.entries(v).forEach(([kSub, vSub]) => deepMerge(curr, kSub, vSub)); } } }; spellRows.forEach(sr => { const rowState = sr.getState(); // returns part of a "spellcasting" item; e.g. `{daily: {1e: [...]} }` if (rowState == null) return; Object.entries(rowState).forEach(([k, v]) => deepMerge(out, k, v)); }); SpellcastingTraitConvert.mutSpellcastingAbility(out); // auto-hide innately cast spells embedded in the header if (out.headerEntries) { const strHeader = JSON.stringify(out.headerEntries); if (/can innately cast {@spell /i.test(strHeader)) out.hidden = [/per day/i.test(strHeader) ? "daily" : "will"]; else delete out.hidden; } else delete out.hidden; if (!Object.keys(out).some(it => it !== "name")) return null; return out; }; const spellRows = []; const doAddSpellRow = (meta, data) => { const row = CreatureBuilder.__$getSpellcastingInput__getSpellGenericRow(spellRows, doUpdateState, meta, data); spellRows.push(row); row.$ele.appendTo($wrpSubRows); }; const $iptName = $(``) .change(() => doUpdateState()); $iptName.val(trait ? trait.name : "Spellcasting"); const $btnToggleHeader = $(``) .click(() => { $btnToggleHeader.toggleClass("active"); $iptHeader.toggleVe($btnToggleHeader.hasClass("active")); doUpdateState(); }) .toggleClass("active", !!(trait && trait.headerEntries)); const $btnToggleFooter = $(``) .click(() => { $btnToggleFooter.toggleClass("active"); $iptFooter.toggleVe($btnToggleFooter.hasClass("active")); doUpdateState(); }) .toggleClass("active", !!(trait && trait.footerEntries)); const _CONTEXT_ENTRIES = [ { display: "Cantrips", type: "0", mode: "cantrip", }, { display: "\uD835\uDC65th level spells", mode: "level", }, null, { display: "Constant effects", type: "constant", mode: "basic", }, { display: "At will spells", type: "will", mode: "basic", }, { display: "\uD835\uDC65/day (/each) spells", type: "daily", mode: "frequency", }, null, { display: "\uD835\uDC65/rest (/each) spells", type: "rest", mode: "frequency", }, { display: "\uD835\uDC65/week (/each) spells", type: "weekly", mode: "frequency", }, { display: "\uD835\uDC65/month (/each) spells", type: "monthly", mode: "frequency", }, { display: "\uD835\uDC65/year (/each) spells", type: "yearly", mode: "frequency", }, ]; const menu = ContextUtil.getMenu(_CONTEXT_ENTRIES.map(contextMeta => { if (contextMeta == null) return; return new ContextUtil.Action( contextMeta.display, async () => { // prevent double-adding switch (contextMeta.type) { case "constant": case "will": if (spellRows.some(it => it.type === contextMeta.type)) return; break; } const meta = {mode: contextMeta.mode, type: contextMeta.type}; if (contextMeta.mode === "level") { const level = await InputUiUtil.pGetUserNumber({min: 1, int: true, title: "Enter Spell Level"}); if (level == null) return; meta.level = level; } // prevent double-adding, round 2 switch (contextMeta.mode) { case "cantrip": case "level": if (spellRows.some(it => it.type === meta.level)) return; break; } doAddSpellRow(meta); doUpdateState(); }, ); })); const $btnAddSpell = $(``) .click((evt) => ContextUtil.pOpenMenu(evt, menu)); const $iptHeader = $(`